WSRPC

Los dos extremos llaman.

WSRPC vuelve simétrico un solo WebSocket. El navegador llama a tus manejadores, y un manejador llama al navegador y espera la respuesta.

navegador servidor conectando

Esta página

    La conexión

      Cada línea de aquí la dibujó una llamada del servidor a esta página. Esta página usa el cliente JavaScript que viene con el paquete, y estas tramas son las que intercambia un servidor de Python.

      La misma llamada desde cualquier extremo

      Instala el paquete y escribe el manejador. Cada pestaña construye el mismo chat que funciona arriba.

      pip install wsrpc-aiohttp

      Una clase de ruta atiende una conexión. El manejador dibuja tu línea en la página y luego responde con una cita.

      server.py
      import random
      
      from aiohttp import web
      from wsrpc_aiohttp import STATIC_DIR, Route, WebSocketAsync, decorators
      
      QUOTES = [
          ("HAL 9000", "I'm sorry, Dave. I'm afraid I can't do that."),
          ("The Oracle", "There is no spoon."),
          ("T-800", "I'll be back."),
      ]
      
      
      class Socket(WebSocketAsync):
          pass
      
      
      class Chat(Route[Socket]):
          @decorators.proxy
          async def send(self, *, text: str) -> dict:
              # Draw the line in the page that called, and wait for its answer.
              await self.socket.proxy.chat.append(text=text, mine=True)
      
              sender, reply = random.choice(QUOTES)
              await self.socket.proxy.chat.append(sender=sender, text=reply)
              return {"delivered": True}
      
      
      def create_app() -> web.Application:
          Socket.add_route("chat", Chat)
          app = web.Application()
          app.router.add_get("/ws/", Socket)
          app.router.add_static("/js/", STATIC_DIR)
          return app
      
      
      if __name__ == "__main__":
          web.run_app(create_app(), host="127.0.0.1", port=8000)
      
      pip install 'wsrpc-asgi[fastapi]'

      La misma clase de ruta, montada en un endpoint WebSocket de FastAPI.

      app.py
      import random
      
      from fastapi import FastAPI, WebSocket
      from wsrpc_asgi import Route, WebSocketAsync, decorators
      
      QUOTES = [
          ("HAL 9000", "I'm sorry, Dave. I'm afraid I can't do that."),
          ("The Oracle", "There is no spoon."),
          ("T-800", "I'll be back."),
      ]
      
      
      class RPC(WebSocketAsync):
          pass
      
      
      class Chat(Route[RPC]):
          @decorators.proxy
          async def send(self, *, text: str) -> dict:
              # Draw the line in the page that called, and wait for its answer.
              await self.socket.proxy.chat.append(text=text, mine=True)
      
              sender, reply = random.choice(QUOTES)
              await self.socket.proxy.chat.append(sender=sender, text=reply)
              return {"delivered": True}
      
      
      RPC.add_route("chat", Chat)
      app = FastAPI()
      
      
      @app.websocket("/ws/")
      async def rpc(websocket: WebSocket) -> None:
          await RPC.serve(websocket)
      
      go get github.com/wsrpc/wsrpc-go

      Un nombre de ruta con punto, y una llamada tipada del manejador de vuelta a la página.

      main.go
      package main
      
      import (
      	"context"
      	"log"
      	"math/rand/v2"
      	"net/http"
      
      	wsrpc "github.com/wsrpc/wsrpc-go"
      	"github.com/wsrpc/wsrpc-go/ws"
      )
      
      type Line struct {
      	Sender string `json:"sender,omitempty"`
      	Text   string `json:"text"`
      	Mine   bool   `json:"mine,omitempty"`
      }
      
      type Sent struct {
      	Text string `json:"text"`
      }
      
      type Delivered struct {
      	Delivered bool `json:"delivered"`
      }
      
      type Seen struct {
      	Seen bool `json:"seen"`
      }
      
      var quotes = []Line{
      	{Sender: "HAL 9000", Text: "I'm sorry, Dave. I'm afraid I can't do that."},
      	{Sender: "The Oracle", Text: "There is no spoon."},
      	{Sender: "T-800", Text: "I'll be back."},
      }
      
      type Chat struct{}
      
      // Draw the line in the page that called, and wait for its answer.
      func (Chat) Send(ctx context.Context, p Sent) (Delivered, error) {
      	peer, _ := wsrpc.FromContext(ctx)
      
      	if _, err := peer.Call[Seen](ctx, "chat.append", Line{Text: p.Text, Mine: true}); err != nil {
      		return Delivered{}, err
      	}
      
      	reply := quotes[rand.IntN(len(quotes))]
      	if _, err := peer.Call[Seen](ctx, "chat.append", reply); err != nil {
      		return Delivered{}, err
      	}
      
      	return Delivered{Delivered: true}, nil
      }
      
      func main() {
      	routes := new(wsrpc.Router)
      	if err := routes.Handle("chat.send", Chat{}.Send); err != nil {
      		log.Fatal(err)
      	}
      
      	rpc, err := ws.NewServer(ws.ServerOptions{Peer: wsrpc.Options{Routes: routes}})
      	if err != nil {
      		log.Fatal(err)
      	}
      
      	mux := http.NewServeMux()
      	mux.Handle("/ws/", rpc)
      	log.Fatal(http.ListenAndServe("127.0.0.1:8000", mux))
      }
      
      pip install wsrpc-aiohttp

      El cliente registra su ruta antes de conectarse, para que el servidor pueda llamarla.

      client.py
      import asyncio
      
      from wsrpc_aiohttp import Route, WSRPCClient, decorators
      
      
      class Chat(Route):
          @decorators.proxy
          def append(self, *, text: str, sender: str = "you", mine: bool = False) -> dict:
              # The server calls this method. Draw the line and confirm it.
              print(f"{sender}: {text}")
              return {"seen": True}
      
      
      async def main() -> None:
          client = WSRPCClient("http://127.0.0.1:8000/ws/")
      
          # Register the route before the connection opens.
          client.add_route("chat", Chat)
      
          async with client:
              print(await client.proxy.chat.send(text="Hello"))
              await asyncio.sleep(1)
      
      
      if __name__ == "__main__":
          asyncio.run(main())
      
      npm install @wsrpc/client

      La página registra la ruta que llama el servidor y dibuja cada línea que recibe.

      chat.ts
      import WSRPC from "@wsrpc/client";
      
      interface Line {
        sender?: string;
        text: string;
        mine?: boolean;
      }
      
      const chat = document.getElementById("chat") as HTMLElement;
      const rpc = new WSRPC("/ws/");
      
      // The server calls this route. The page draws what it gets.
      rpc.addRoute("chat.append", (line: Line) => {
        const row = document.createElement("p");
        row.textContent = `${line.sender ?? "you"}: ${line.text}`;
        chat.append(row);
        return { seen: true };
      });
      
      rpc.connect();
      
      export async function say(text: string): Promise<void> {
        await rpc.call<{ delivered: boolean }>("chat.send", { text });
      }
      
      wsrpc-client.d.ts
      // The package ships wsrpc.d.ts. Map it onto the module name once.
      declare module "@wsrpc/client" {
        export default class WSRPC {
          constructor(url: string, reconnectTimeout?: number);
          connect(): void;
          destroy(): void;
          state(): "CONNECTING" | "OPEN" | "CLOSING" | "CLOSED";
          call<T>(method: string, params?: object): Promise<T>;
          addRoute(
            route: string,
            handler: (params: never) => unknown,
            isAsync?: boolean,
          ): void;
        }
      }
      

      El script incluido añade un global WSRPC. Sin paso de compilación ni bundler.

      index.html
      <form id="say"><input name="text" autocomplete="off"></form>
      <div id="chat"></div>
      
      <script src="/js/wsrpc.min.js"></script>
      <script>
        const rpc = new WSRPC("/ws/");
      
        // The server calls this route. The page draws what it gets.
        rpc.addRoute("chat.append", function (line) {
          const row = document.createElement("p");
          row.textContent = (line.sender || "you") + ": " + line.text;
          document.getElementById("chat").append(row);
          return {seen: true};
        });
      
        rpc.connect();
      
        document.getElementById("say").addEventListener("submit", function (event) {
          event.preventDefault();
          rpc.proxy.chat.send({text: event.target.text.value});
          event.target.reset();
        });
      </script>
      

      Qué ofrece la conexión

      Llamadas en ambos sentidos

      Un manejador llama a una ruta que registró el navegador y espera su resultado en el mismo socket.

      Rutas o funciones simples

      Expón una función, o una clase de ruta cuya instancia vive tanto como la conexión que atiende.

      Muchas llamadas a la vez

      Cada llamada lleva su propio id, así los resultados vuelven a quien llamó, en cualquier orden.

      Los errores remotos se lanzan en local

      Un fallo del otro extremo llega con su tipo y su mensaje, y se lanza donde hiciste la llamada.

      Señales del ciclo de vida

      Observa la conexión, la desconexión y cada llamada sin envolver ningún manejador.

      El código bloqueante no ocupa el bucle

      Los manejadores asíncronos corren en el bucle de eventos. Los síncronos, en un pool de hilos.

      Cómo viaja una llamada

      Cada trama es un objeto JSON. La llamada lleva un id, un método y parámetros; la respuesta vuelve con el mismo id. Ambos extremos envían llamadas, así que un manejador puede preguntar al navegador mientras atiende una.

      navegador servidor {"id": 3, "method": "chat.send", "params": {"text": "hi"}} {"id": 3, "result": {"delivered": true}}
      El navegador llama al servidor.
      navegador servidor manejador {"id": 5, "method": "chat.send", "params": {"text": "hi"}} {"id": 2, "method": "chat.append", "params": {"text": "hi"}} {"id": 2, "result": {"seen": true}} {"id": 5, "result": {"delivered": true}}
      El servidor llama al navegador dentro de la llamada que atiende.

      Dónde funciona

      Cada framework aquí tiene un ejemplo ejecutable y una prueba en su repositorio.

      Servidor aiohttp

      wsrpc-aiohttp

      • aiohttp

      Servidores ASGI

      wsrpc-asgi

      • FastAPI
      • Starlette
      • Django
      • Channels
      • Litestar
      • Quart
      • Sanic
      • Falcon
      • BlackSheep
      • ASGI

      Servidores Node.js

      @wsrpc/server

      • node:http
      • Express
      • Fastify

      Servidores Go

      github.com/wsrpc/wsrpc-go

      • net/http

      Clientes

      @wsrpc/client

      • Browsers
      • Node.js
      • Cloudflare Workers

      ¿Qué paquete de Python?

      Dos implementaciones, un protocolo. Un par en Python, un par en el navegador y un servidor se combinan libremente.

      wsrpc-aiohttpwsrpc-asgi
      Servidor manejador web de aiohttp ruta ASGI, middleware o aplicación independiente
      Instalación pip install wsrpc-aiohttp pip install 'wsrpc-asgi[fastapi,client]'
      Cliente Python sobre aiohttp sobre websockets
      Cliente JavaScript incluido el mismo cliente
      Códec JSON loads y dumps se sustituyen igual