Direct Sockets

Demián Renzulli
Demián Renzulli
Andrew Rayskiy
Andrew Rayskiy
Vlad Krot
Vlad Krot

Les applications Web standards sont généralement limitées à des protocoles de communication spécifiques tels que HTTP et à des API telles que WebSocket et WebRTC. Bien qu'elles soient puissantes, elles sont conçues pour être étroitement limitées afin d'éviter les utilisations abusives. Elles ne peuvent pas établir de connexions TCP ou UDP brutes, ce qui limite la capacité des applications Web à communiquer avec les anciens systèmes ou les appareils matériels qui utilisent leurs propres protocoles non Web. Par exemple, vous pouvez créer un client SSH Web, vous connecter à une imprimante locale ou gérer un parc d'appareils IoT. Historiquement, cela nécessitait des plug-ins de navigateur ou des applications d'assistance natives.

L'API Direct Sockets permet de surmonter cette limite en permettant aux applications Web isolées (AWI) d'établir des connexions TCP et UDP directes sans serveur relais. Grâce à des mesures de sécurité supplémentaires, telles que la stricte Content Security Policy (CSP) et l'isolation multi-origine, cette API peut être exposée en toute sécurité.

Cas d'utilisation

Quand utiliser Direct Sockets plutôt que des WebSockets standards ?

  • IoT et appareils connectés : communication avec du matériel qui utilise TCP/UDP brut plutôt que HTTP.
  • Anciens systèmes : connexion à d'anciens serveurs de messagerie (SMTP/IMAP), à des serveurs de chat IRC ou à des imprimantes.
  • Bureau à distance et terminaux : implémentation de clients SSH, Telnet ou RDP.
  • Systèmes P2P : implémentation de tables de hachage distribuées (DHT) ou d'outils de collaboration résilients (comme IPFS).
  • Diffusion de contenu multimédia : utilisation d'UDP pour diffuser du contenu vers plusieurs points de terminaison à la fois (multidiffusion), ce qui permet des cas d'utilisation tels que la lecture coordonnée de vidéos sur un réseau de bornes interactives.
  • Capacités du serveur et de l'écouteur : configuration de l'IWA pour qu'il agisse en tant que point de terminaison de réception pour les connexions TCP ou les datagrammes UDP entrants à l'aide de TCPServerSocket ou de UDPSocket lié.

Conditions préalables pour Direct Sockets

Avant d'utiliser les sockets directs, vous devez configurer une IWA fonctionnelle. Vous pouvez ensuite intégrer les sockets directs à vos pages.

Ajouter une règle d'autorisation

Pour utiliser Direct Sockets, vous devez configurer l'objet permissions_policy dans le fichier manifeste de votre AWI. Vous devez ajouter la clé direct-sockets pour activer explicitement l'API. Vous devez également inclure la clé cross-origin-isolated. Cette clé n'est pas spécifique aux sockets directs, mais elle est requise pour toutes les IWA et détermine si le document peut accéder aux API qui nécessitent l'isolation multi-origine.

{
  "permissions_policy": {
    "direct-sockets": ["self"],
    "cross-origin-isolated": ["self"]
  }
}

La clé direct-sockets détermine si les appels à new TCPSocket(...), new TCPServerSocket(...) ou new UDPSocket(...) sont autorisés. Si cette règle n'est pas définie, ces constructeurs seront immédiatement rejetés avec un NotAllowedError.

Implémenter TCPSocket

Les applications peuvent demander une connexion TCP en créant une instance TCPSocket.

Ouvrir une connexion

Pour ouvrir une connexion, utilisez l'opérateur new et await la promesse ouverte.

Le constructeur TCPSocket lance la connexion à l'aide des remoteAddress et remotePort spécifiés.

const remoteAddress = 'example.com';
const remotePort = 7;

// Configure options like keepAlive or buffering
const options = {
  keepAlive: true,
  keepAliveDelay: 720000
};

let tcpSocket = new TCPSocket(remoteAddress, remotePort, options);

// Wait for the connection to be established
let { readable, writable } = await tcpSocket.opened;

L'objet de configuration facultatif permet un contrôle précis du réseau. Dans ce cas précis, keepAliveDelay est défini sur 720 000 millisecondes pour maintenir la connexion pendant les périodes d'inactivité. Les développeurs peuvent également configurer d'autres propriétés ici, telles que noDelay, qui désactive l'algorithme de Nagle pour empêcher le système de regrouper les petits paquets (ce qui peut réduire la latence), ou sendBufferSize et receiveBufferSize pour gérer le débit.

Dans la dernière partie de l'extrait précédent, le code attend la promesse ouverte, qui n'est résolue qu'une fois l'établissement de la connexion terminé. Il renvoie un objet TCPSocketOpenInfo contenant les flux lisibles et inscriptibles requis pour la transmission des données.

Lecture et écriture

Une fois le socket ouvert, interagissez avec lui à l'aide des interfaces standards de l'API Streams.

  • Écriture : le flux accessible en écriture accepte un BufferSource (comme un ArrayBuffer).
  • Lecture : le flux lisible génère des données Uint8Array.
// Writing data
const writer = writable.getWriter();
const encoder = new TextEncoder();
await writer.write(encoder.encode("Hello Server"));

// Call when done
writer.releaseLock();

// Reading data
const reader = readable.getReader();
const { value, done } = await reader.read();
if (!done) {
    const decoder = new TextDecoder();
    console.log("Received:", decoder.decode(value));
}

// Call when done
reader.releaseLock();

Lecture optimisée avec BYOB

Pour les applications hautes performances où la gestion de l'allocation de mémoire est essentielle, l'API est compatible avec la lecture "Bring Your Own Buffer" (BYOB). Au lieu de laisser le navigateur allouer un nouveau tampon pour chaque bloc de données reçu, vous pouvez transmettre un tampon préalloué au lecteur. Cela réduit la surcharge de la récupération de mémoire en écrivant les données directement dans votre mémoire existante.

// 1. Get a BYOB reader explicitly
const reader = readable.getReader({ mode: 'byob' });

// 2. Allocate a reusable buffer (e.g., 4KB)
let buffer = new Uint8Array(4096);

// 3. Read directly into the existing buffer
const { value, done } = await reader.read(buffer);

if (!done) {
  // 'value' is a view of the data written directly into your buffer
  console.log("Bytes received:", value.byteLength);
}

reader.releaseLock();

Implémenter UDPSocket

La classe UDPSocket permet la communication UDP. Il fonctionne dans deux modes distincts selon la façon dont vous configurez les options.

Mode connecté

Dans ce mode, le socket communique avec une seule destination spécifique. Cela est utile pour les tâches client-serveur standards.

// Connect to a specific remote host
let udpSocket = new UDPSocket({
    remoteAddress: 'example.com',
    remotePort: 7 });

let { readable, writable } = await udpSocket.opened;

Mode lié

Dans ce mode, le socket est lié à un point de terminaison IP local. Il peut recevoir des datagrammes de sources arbitraires et les envoyer à des destinations arbitraires. Cette méthode est souvent utilisée pour les protocoles de découverte locale ou le comportement de type serveur.

// Bind to all interfaces (IPv6)
let udpSocket = new UDPSocket({
    localAddress: '::'
    // omitting localPort lets the OS pick one
});

// localPort will tell you the OS-selected port.
let { readable, writable, localPort } = await udpSocket.opened;

Gérer les messages UDP

Contrairement au flux d'octets TCP, les flux UDP traitent des objets UDPMessage, qui contiennent les données et les informations sur l'adresse distante. Le code suivant montre comment gérer les opérations d'entrée/sortie lorsque vous utilisez un UDPSocket en "mode lié".

// Writing (Bound Mode requires specifying destination)
const writer = writable.getWriter();
await writer.write({
    data: new TextEncoder().encode("Ping"),
    remoteAddress: '192.168.1.50',
    remotePort: 8080
});

// Reading
const reader = readable.getReader();
const { value } = await reader.read();
// value contains: { data, remoteAddress, remotePort }
console.log(`Received from ${value.remoteAddress}:`, value.data);

Contrairement au "mode connecté", où le socket est verrouillé sur un pair spécifique, le mode lié permet au socket de communiquer avec des destinations arbitraires. Par conséquent, lorsque vous écrivez des données dans le flux accessible en écriture, vous devez transmettre un objet UDPMessage qui spécifie explicitement le remoteAddress et le remotePort pour chaque paquet, en indiquant au socket exactement où acheminer ce datagramme spécifique. De même, lors de la lecture à partir du flux lisible, la valeur renvoyée inclut non seulement la charge utile de données, mais aussi les remoteAddress et remotePort de l'expéditeur, ce qui permet à votre application d'identifier l'origine de chaque paquet entrant.

Remarque : Lorsque vous utilisez UDPSocket en "mode connecté", le socket est effectivement verrouillé sur un pair spécifique, ce qui simplifie le processus d'E/S. Dans ce mode, les propriétés remoteAddress et remotePort sont effectivement des no-ops lors de l'écriture, car la destination est déjà fixe. De même, lors de la lecture des messages, ces propriétés renverront la valeur "null", car la source est garantie d'être le pair connecté.

Prise en charge du multicast

Pour des cas d'utilisation tels que la synchronisation de la lecture vidéo sur plusieurs bornes ou l'implémentation de la détection d'appareils locaux (par exemple, mDNS), Direct Sockets est compatible avec le protocole UDP multicast. Cela permet d'envoyer des messages à une adresse de "groupe" et de les faire recevoir par tous les abonnés du réseau, plutôt qu'à un seul pair spécifique.

Autorisations de multidiffusion

Pour utiliser les fonctionnalités de multidiffusion, vous devez ajouter l'autorisation direct-sockets-multicast spécifique au fichier manifeste de votre PWA. Cette autorisation est différente de l'autorisation standard pour les sockets directs. Elle est nécessaire, car le multicast n'est utilisé que dans les réseaux privés.

{
  "permissions_policy": {
    "direct-sockets": ["self"],
    "direct-sockets-multicast": ["self"],
    "direct-sockets-private": ["self"],
    "cross-origin-isolated": ["self"]
  }
}

Envoyer des datagrammes multicast

L'envoi à un groupe multicast est très semblable au mode connecté UDP standard, avec l'ajout d'options spécifiques pour contrôler le comportement des paquets.

const MULTICAST_GROUP = '239.0.0.1';
const PORT = 12345;

const socket = new UDPSocket({
  remoteAddress: MULTICAST_GROUP,
  remotePort: PORT,
  // Time To Live: How many router hops the packet can survive (default: 1)
  multicastTimeToLive: 5,
  // Loopback: Whether to receive your own packets (default: true)
  multicastLoopback: true
});

const { writable } = await socket.opened;
// Write to the stream as usual...

Recevoir des datagrammes multidiffusion

Pour recevoir du trafic multicast, vous devez ouvrir un UDPSocket en "mode lié" (en le liant généralement à 0.0.0.0 ou ::), puis rejoindre un groupe spécifique à l'aide de MulticastController. Vous pouvez également utiliser l'option multicastAllowAddressSharing (semblable à SO_REUSEADDR sur Unix), qui est essentielle pour les protocoles de découverte d'appareils où plusieurs applications sur le même appareil doivent écouter le même port.

const socket = new UDPSocket({
  localAddress: '0.0.0.0', // Listen on all interfaces
  localPort: 12345,
  multicastAllowAddressSharing: true // Allow multiple applications to bind to the same address / port pair.
});

// The open info contains the MulticastController
const { readable, multicastController } = await socket.opened;

// Join the group to start receiving packets
await multicastController.joinGroup('239.0.0.1');

const reader = readable.getReader();

// Read the stream...
const { value } = await reader.read();
console.log(`Received multicast from ${value.remoteAddress}`);

// When finished, you can leave the group (this is an optional, but recommended practice)
await multicastController.leaveGroup('239.0.0.1');

Créer un serveur

L'API est également compatible avec TCPServerSocket pour accepter les connexions TCP entrantes, ce qui permet à votre IWA d'agir en tant que serveur local. Le code suivant illustre comment établir un serveur TCP à l'aide de l'interface TCPServerSocket.

// Listen on all interfaces (IPv6)
let tcpServerSocket = new TCPServerSocket('::');

// Accept connections via the readable stream
let { readable } = await tcpServerSocket.opened;
let reader = readable.getReader();

// Wait for a client to connect
let { value: clientSocket } = await reader.read();

// 'clientSocket' is a standard TCPSocket you can now read/write to

En instanciant la classe avec l'adresse '::', le serveur se lie à toutes les interfaces réseau IPv6 disponibles pour écouter les tentatives entrantes. Contrairement aux API de serveur traditionnelles basées sur des rappels, cette API utilise le modèle de l'API Streams du Web : les connexions entrantes sont fournies sous la forme d'un ReadableStream. Lorsque vous appelez reader.read(), l'application attend et accepte la prochaine connexion de la file d'attente, en résolvant une valeur qui est une instance TCPSocket entièrement fonctionnelle, prête pour la communication bidirectionnelle avec ce client spécifique.

Déboguer les sockets directs avec les outils pour les développeurs Chrome

À partir de Chrome 138, vous pouvez déboguer le trafic Direct Sockets directement dans le panneau Réseau des outils pour les développeurs Chrome, ce qui vous évite d'utiliser des renifleurs de paquets externes. Cet outil vous permet de surveiller les connexions TCPSocket ainsi que le trafic UDPSocket (en mode lié et connecté) en plus de vos requêtes HTTP standards.

Pour inspecter l'activité réseau de votre application :

  1. Ouvrez le panneau Réseau dans les outils pour les développeurs Chrome.
  2. Recherchez et sélectionnez la connexion de socket dans le tableau des requêtes.
  3. Ouvrez l'onglet Messages pour afficher un journal de toutes les données transmises et reçues.

Données de l'onglet "Messages" des outils de développement.

Cette vue fournit un lecteur hexadécimal qui vous permet d'inspecter la charge utile binaire brute de vos messages TCP et UDP, afin de vous assurer que l'implémentation de votre protocole est parfaite au niveau des octets.

Démo

IWA Kitchen Sink présente une application avec plusieurs onglets, chacun illustrant une API IWA différente, comme Direct Sockets, Controlled Frame et plus encore.

Vous pouvez également consulter la démonstration du client Telnet, qui contient une application Web isolée permettant à l'utilisateur de se connecter à un serveur TCP/IP via un terminal interactif. En d'autres termes, un client Telnet.

Conclusion

L'API Direct Sockets comble une lacune fonctionnelle essentielle en permettant aux applications Web de gérer les protocoles réseau bruts qui étaient auparavant impossibles à prendre en charge sans wrappers natifs. Il va au-delà de la simple connectivité client : avec TCPServerSocket, les applications peuvent écouter les connexions entrantes, tandis que UDPSocket offre des modes flexibles pour la communication peer-to-peer et la découverte du réseau local.

En exposant ces fonctionnalités TCP et UDP brutes via l'API Streams moderne, vous pouvez désormais créer des implémentations complètes de protocoles anciens (comme SSH, RDP ou des normes IoT personnalisées) directement en JavaScript. Étant donné que cette API accorde un accès réseau de bas niveau, elle a des implications importantes en termes de sécurité. Par conséquent, il est limité aux applications Web isolées (AWI), ce qui garantit que ce pouvoir n'est accordé qu'aux applications de confiance, explicitement installées, qui appliquent des règles de sécurité strictes. Cet équilibre vous permet de créer des applications puissantes axées sur les appareils tout en maintenant la sécurité que les utilisateurs attendent de la plate-forme Web.

Ressources