NodeWorld

Web, WebSocket et images

Depuis SDK 0.2.1

Un script FL# atteint Internet avec les classes de .NET : System.Net.Http.HttpClient (avec HttpRequestMessage, HttpResponseMessage, HttpContent et ses variantes) et System.Net.WebSockets.ClientWebSocket. Elles ont les signatures de .NET 10 : le code et les bibliothèques écrits pour .NET marchent tels quels. Les images arrivent avec Texture.LoadFromUrl et ImageConversion.LoadImage.

Sur l'appareil d'un joueur, chaque adresse passe par la porte réseau du jeu : la première fois qu'un monde contacte un site, le joueur choisit Toujours, Cette fois ou Refuser dans une fenêtre (voir Les sites des mondes). Ta requête attend sa réponse. Sur le serveur, toutes les adresses sont autorisées et personne n'est sollicité.

HTTP

csharp
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using NodeWorld.Flux;

namespace MonMonde
{
    public class Scores : FluxScript
    {
        private static readonly HttpClient http = new HttpClient { BaseAddress = new Uri("https://api.example.com/") };

        protected override void Start()
        {
            _ = ChargeAsync();
        }

        private async Task ChargeAsync()
        {
            try
            {
                http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
                string json = await http.GetStringAsync("scores?top=10");
                Debug.Log("Scores : " + json);

                var corps = new StringContent("{\"player\":\"" + Player.name + "\",\"score\":42}", Encoding.UTF8, "application/json");
                HttpResponseMessage reponse = await http.PostAsync("scores", corps);
                Debug.Log("Enregistré : " + (int)reponse.StatusCode);
            }
            catch (HttpRequestException exception)
            {
                Debug.LogWarning("Les scores ne sont pas disponibles : " + exception.Message);
            }
        }
    }
}

Ce que tu peux faire :

  • Toutes les méthodes (GET, POST, PUT, PATCH, DELETE…), tous les en-têtes sauf Host et ceux de la connexion, des corps en octets (ByteArrayContent, StreamContent, ReadOnlyMemoryContent), du texte (StringContent), des formulaires (FormUrlEncodedContent, MultipartFormDataContent).
  • Le statut, les en-têtes et le corps de la réponse : ReadAsStringAsync, ReadAsByteArrayAsync, ReadAsStreamAsync.
  • Les redirections sont suivies (50 au plus, comme .NET), jamais de https vers http. Une redirection vers un autre site repasse par la porte.
  • Les réponses gzip et deflate sont décodées si tu le demandes : new HttpClient(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate }).
  • HttpClient.Timeout (100 s par défaut) et un CancellationToken arrêtent une requête.

Ce qui change par rapport à .NET sur un PC :

  • Seulement http et https. Le corps d'une réponse arrive en entier : ResponseHeadersRead le donne aussi en entier.
  • Pas de cookies (UseCookies reste false), pas de proxy, pas d'identifiants, pas de certificat client, pas de vérification de certificat à ta façon : le certificat d'un site doit être valide.
  • Seulement les méthodes asynchrones : HttpClient.Send lève NotSupportedException (un script n'attend jamais sur son fil).
  • Quand le joueur refuse le site, la requête échoue avec HttpRequestException, HttpRequestError.ConnectionError et un message qui nomme le site.

WebSocket

csharp
using System;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using NodeWorld.Flux;

namespace MonMonde
{
    public class Chat : FluxScript
    {
        private ClientWebSocket socket;

        protected override void Start()
        {
            _ = TourneAsync();
        }

        private async Task TourneAsync()
        {
            socket = new ClientWebSocket();
            socket.Options.AddSubProtocol("chat");
            await socket.ConnectAsync(new Uri("wss://chat.example.com/room"), CancellationToken.None);
            await socket.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes("bonjour")), WebSocketMessageType.Text, true, CancellationToken.None);

            var tampon = new byte[4096];
            while (socket.State == WebSocketState.Open)
            {
                WebSocketReceiveResult resultat = await socket.ReceiveAsync(new ArraySegment<byte>(tampon), CancellationToken.None);
                if (resultat.MessageType == WebSocketMessageType.Close) break;
                Debug.Log(Encoding.UTF8.GetString(tampon, 0, resultat.Count));
            }
        }

        protected override void OnDestroy()
        {
            socket?.Abort();
        }
    }
}

ws et wss, messages texte et binaires, messages en plusieurs morceaux (endOfMessage), la fermeture en deux temps (CloseAsync, CloseOutputAsync), Abort. Options.KeepAliveInterval (30 s par défaut) garde la connexion éveillée. Pas d'extension de compression ; un serveur qui répond autre chose que 101 fait échouer la connexion, comme dans .NET.

Images

csharp
Texture affiche = await Texture.LoadFromUrl("https://example.com/affiche.png");
GetComponent<Renderer>().sharedMaterial.SetTexture("_BaseMap", affiche);
Debug.Log(affiche.width + " x " + affiche.height);

Texture.LoadFromUrl charge un PNG ou un JPG en passant par la porte et le décode en texture du monde. ImageConversion.LoadImage(octets) décode des octets que tu as déjà (de GetByteArrayAsync, d'un WebSocket…) et donne null si ce n'est pas une image. Les textures sont détruites avec le monde, ou par Object.Destroy. Sur le serveur, il n'y a pas d'image : LoadImage donne null.

Ce qui est demandé au joueur

  • Le choix est gardé par site : le domaine et ses sous-domaines. Autoriser example.com autorise aussi api.example.com et cdn.example.com. L'endroit où commence un domaine vient de la Public Suffix List : autoriser monnom.github.io n'autorise pas le reste de github.io. Un port qui n'est pas le port standard fait partie du site : example.com:8443.
  • Une fenêtre par site : toutes tes requêtes vers lui attendent la même réponse, tant que le joueur n'a pas répondu, ou jusqu'à ce qu'il quitte le monde.
  • Toujours et Refuser sont gardés sur l'appareil du joueur, pour tous les mondes, jusqu'à ce qu'il les retire dans Paramètres, Sites. Cette fois dure jusqu'à ce qu'il quitte le monde.
  • Les grands sites vidéo (youtube.com, youtu.be, twitch.tv, vimeo.com, soundcloud.com, dailymotion.com) sont autorisés sans fenêtre ; le joueur peut modifier cette liste.
  • Une adresse IP de la machine ou du réseau local du joueur écrite dans ton adresse (192.168.1.20, 127.0.0.1…) ouvre la fenêtre avec un avertissement. Un nom qui pointe vers elles est toujours refusé.
  • Le jeu résout le nom lui-même et se connecte à l'adresse qu'il a contrôlée : un nom qui change de réponse après le contrôle n'atteint rien de nouveau.
  • En Play mode dans l'éditeur Unity, tout est autorisé, sans fenêtre.

Vidéos

Un VideoPlayer demande à la porte le site de son adresse avant de l'ouvrir (VideoState.Loading pendant ce temps). Un site refusé donne VideoState.Error avec VideoError.Refused. Tous les protocoles réseau du lecteur vidéo marchent (http, https, rtmp, rtsp, udp, mms, srt…) ; un fichier, l'écran ou un périphérique de capture, non.

Sur le serveur

Les scripts marqués [ServerOnly] ont les mêmes HttpClient et ClientWebSocket, avec toutes les adresses autorisées : aucun joueur n'est là pour répondre. Ils atteignent aussi la machine du serveur et son réseau local : l'hébergeur décide de ce qu'ils peuvent atteindre avec son pare-feu (voir Ports et pare-feu).

Journaux

Chaque requête écrit une ligne [Web] avec le site seulement, jamais le chemin : [Web] GET api.example.com: 200, 512 bytes. La fenêtre, la réponse du joueur, chaque ouverture et fermeture de WebSocket écrivent aussi la leur.