WSRPC

Both ends can call.

WSRPC makes one WebSocket symmetric. The browser calls your handlers, and a handler calls back into the browser and waits for the answer.

browser server connecting

This page

    The wire

      Every line here was drawn by a call from the server into this page. This page runs the JavaScript client that ships with the package, and these frames are the ones a Python server exchanges.

      The same call from every side

      Install the package

      pip install wsrpc-aiohttp

      What the code does

      The page calls chat.send and waits, and before it answers, the handler calls chat.append back in the page — once with your line, once with the reply. The server draws both lines.

      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)
      

      Install the package

      pip install 'wsrpc-asgi[fastapi]'

      What the code does

      The page calls chat.send and waits, and before it answers, the handler calls chat.append back in the page — once with your line, once with the reply. The server draws both lines.

      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)
      

      Install the package

      go get github.com/wsrpc/wsrpc-go

      What the code does

      The page calls chat.send and waits, and before it answers, the handler calls chat.append back in the page — once with your line, once with the reply. The server draws both lines.

      main.go
      package main
      
      import (
      	"context"
      	"errors"
      	"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"`
      }
      
      // One place names each route and fixes its parameter and result types.
      var (
      	ChatSend   = wsrpc.Define[Sent, Delivered]("chat.send")
      	ChatAppend = wsrpc.Define[Line, Seen]("chat.append")
      )
      
      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, ok := wsrpc.FromContext(ctx)
      	if !ok {
      		return Delivered{}, errors.New("no RPC context")
      	}
      
      	if _, err := ChatAppend.Call(ctx, peer, Line{Text: p.Text, Mine: true}); err != nil {
      		return Delivered{}, err
      	}
      
      	reply := quotes[rand.IntN(len(quotes))]
      	if _, err := ChatAppend.Call(ctx, peer, reply); err != nil {
      		return Delivered{}, err
      	}
      
      	return Delivered{Delivered: true}, nil
      }
      
      func main() {
      	routes := new(wsrpc.Router)
      	if err := ChatSend.Handle(routes, 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))
      }
      

      Install the package

      pip install wsrpc-aiohttp

      What the code does

      The page calls chat.send and waits, and before it answers, the handler calls chat.append back in the page — once with your line, once with the reply. The server draws both lines.

      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())
      

      Install the package

      npm install @wsrpc/client

      What the code does

      The page calls chat.send and waits, and before it answers, the handler calls chat.append back in the page — once with your line, once with the reply. The server draws both lines.

      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;
        }
      }
      

      What the code does

      The page calls chat.send and waits, and before it answers, the handler calls chat.append back in the page — once with your line, once with the reply. The server draws both lines.

      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>
      

      What the connection gives you

      Calls in both directions

      A handler calls a route that the browser registered, and awaits its result on the same socket.

      Routes or plain functions

      Expose a function, or a route class whose instance lives as long as the connection it serves.

      Many calls at once

      Every call carries its own id, so results return to their own callers in any order.

      Remote errors raise locally

      A failure on the far side arrives with its type and message, and raises where you made the call.

      Signals for the lifecycle

      Watch connect, disconnect and each call without wrapping a single handler.

      Blocking code stays off the loop

      Asynchronous handlers run on the event loop. Synchronous handlers run in a thread pool.

      The connection is an object

      A route class is created for each connection and lives as long as it, so state belongs in the instance instead of a dictionary keyed by socket. While a method runs, the library knows which connection it serves, so a call to the other side reads like a local one.

      Press a product: only the call Cart.add with one name goes over the wire, and the list stays in the object on the server. Reopen the connection and the server builds a new object, while the page replays its calls on onconnect.

      cart.py
      class Cart(Route[Socket]):
          def __init__(self, socket: Socket) -> None:
              super().__init__(socket)
              self.items: list[str] = []
      
          @decorators.proxy
          def init(self) -> int:
              return len(self.items)
      
          @decorators.proxy
          async def add(self, *, name: str) -> int:
              self.items.append(name)
      
              # Only the new item travels. The list stays in this object.
              await self.socket.proxy.Page.add(name=name)
              return len(self.items)
      
      cart.go
      // One place names each route and fixes its parameter and result types.
      var (
      	CartInit = wsrpc.Define[struct{}, int]("Cart")
      	CartAdd  = wsrpc.Define[Item, int]("Cart.add")
      	PageAdd  = wsrpc.Define[Item, Seen]("Page.add")
      )
      
      // Cart belongs to one connection. The factory below builds it.
      type Cart struct {
      	items []string
      }
      
      func (c *Cart) Init(ctx context.Context, _ struct{}) (int, error) {
      	return len(c.items), nil
      }
      
      func (c *Cart) Add(ctx context.Context, p Item) (int, error) {
      	c.items = append(c.items, p.Name)
      
      	peer, ok := wsrpc.FromContext(ctx)
      	if !ok {
      		return 0, errors.New("no RPC context")
      	}
      
      	// Only the new item travels. The list stays in this object.
      	if _, err := PageAdd.Call(ctx, peer, p); err != nil {
      		return 0, err
      	}
      	return len(c.items), nil
      }
      
      // The factory runs once for each connection, before any remote call.
      func newCart(peer *wsrpc.Peer, routes *wsrpc.Router) (func(), error) {
      	cart := &Cart{}
      	if err := CartInit.Handle(routes, cart.Init); err != nil {
      		return nil, err
      	}
      	if err := CartAdd.Handle(routes, cart.Add); err != nil {
      		return nil, err
      	}
      	return func() {}, nil
      }
      
      cart.ts
      import { Route } from "@wsrpc/client/core";
      
      /** A cart that belongs to one connection. */
      export class Cart extends Route {
        static override readonly methods = new Set(["init", "add"]);
      
        private items: string[] = [];
      
        init(): number {
          return this.items.length;
        }
      
        async add(params: { name: string }): Promise<number> {
          this.items.push(params.name);
      
          // Only the new item travels. The list stays in this object.
          await this.socket.call("Page.add", params);
          return this.items.length;
        }
      }
      

      How a call travels

      Every frame is one JSON object. A call carries an id, a method and params; the answer comes back with the same id. Both peers send calls, so a handler can ask the browser a question while it serves one.

      browser server {"id": 3, "method": "chat.send", "params": {"text": "hi"}} {"id": 3, "result": {"delivered": true}}
      The browser calls the server.
      browser server handler {"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}}
      The server calls the browser inside the call it is serving.

      Where it runs

      Every implementation is both a server and a client. Each framework here has a runnable example and a test in its repository.

      Python, aiohttp

      wsrpc-aiohttp

      • aiohttp

      Documentation

      Python, ASGI

      wsrpc-asgi

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

      Documentation

      Node.js and the browser

      @wsrpc/server
      @wsrpc/client

      • node:http
      • Express
      • Fastify
      • Cloudflare Workers

      Documentation

      Go

      github.com/wsrpc/wsrpc-go

      • net/http

      Documentation