WSRPC

どちらの端からも呼び出せます。

WSRPC は 1 本の WebSocket を対称にします。ブラウザがハンドラを呼び、ハンドラはブラウザを呼んで結果を待ちます。

ブラウザ サーバ 接続中

このページ

    接続

      ここの各行は、サーバがこのページを呼び出して描いたものです。 このページはパッケージ同梱の JavaScript クライアントで動きます。これらのフレームは Python サーバがやり取りするものと同じです。

      どちらの側からも同じ呼び出し

      パッケージを入れて、ハンドラを書きます。どのタブも、上で動いているのと同じチャットを作ります。

      pip install wsrpc-aiohttp

      ルートクラスは 1 つの接続を担当します。ハンドラはあなたの行をページに描き、続けて台詞で答えます。

      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]'

      同じルートクラスを、FastAPI の WebSocket エンドポイントに載せます。

      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

      ドット付きのルート名と、ハンドラからページへ戻る型付きの呼び出しです。

      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

      クライアントは接続前にルートを登録します。そうすればサーバから呼び出せます。

      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

      ページはサーバが呼ぶルートを登録し、受け取った行をすべて描きます。

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

      同梱スクリプトがグローバルの WSRPC を用意します。ビルドもバンドラも不要です。

      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>
      

      この接続で得られるもの

      双方向の呼び出し

      ハンドラはブラウザが登録したルートを呼び、同じソケットで結果を待ちます。

      ルートクラスか素の関数

      関数を公開できます。ルートクラスを公開すると、そのインスタンスは接続と同じ寿命になります。

      同時に多数の呼び出し

      呼び出しごとに id を持つので、結果は順序に関係なく各呼び出し元へ戻ります。

      遠端のエラーをローカルで送出

      相手側の失敗は型とメッセージを伴って届き、呼び出した場所で例外になります。

      ライフサイクルのシグナル

      接続、切断、各呼び出しを観測できます。ハンドラを包む必要はありません。

      ブロッキング処理はループを止めない

      非同期ハンドラはイベントループで動き、同期ハンドラはスレッドプールで動きます。

      呼び出しの流れ

      各フレームは 1 つの JSON オブジェクトです。呼び出しは id、メソッド、パラメータを運び、応答は同じ id で戻ります。両端が呼び出しを送れるため、ハンドラは呼び出しの処理中にブラウザへ問い合わせできます。

      ブラウザ サーバ {"id": 3, "method": "chat.send", "params": {"text": "hi"}} {"id": 3, "result": {"delivered": true}}
      ブラウザがサーバを呼び出します。
      ブラウザ サーバ ハンドラ {"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}}
      サーバは処理中の呼び出しの中でブラウザを呼び出します。

      動作環境

      ここにある各フレームワークには、実行できる例とリポジトリ内のテストがあります。

      aiohttp サーバ

      wsrpc-aiohttp

      • aiohttp

      ASGI サーバ

      wsrpc-asgi

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

      Node.js サーバ

      @wsrpc/server

      • node:http
      • Express
      • Fastify

      Go サーバ

      github.com/wsrpc/wsrpc-go

      • net/http

      クライアント

      @wsrpc/client

      • Browsers
      • Node.js
      • Cloudflare Workers

      どちらの Python パッケージか

      実装は 2 つ、プロトコルは 1 つです。Python ピア、ブラウザピア、サーバを自由に組み合わせられます。

      wsrpc-aiohttpwsrpc-asgi
      サーバ aiohttp のハンドラ ASGI のルート、ミドルウェア、単体アプリ
      インストール pip install wsrpc-aiohttp pip install 'wsrpc-asgi[fastapi,client]'
      Python クライアント aiohttp ベース websockets ベース
      JavaScript クライアント 同梱 同じクライアント
      JSON コーデック loads と dumps を差し替え可能 同じ