📙

Cap'n Webについて

に公開

Cap'n Webが面白いのでその紹介です。

Cap'n Web概要

Cap'n WebはJavaScriptネイティブなRPCの実装でcapnwebというnpmライブラリが提供されています。作者がCloudflare Workersを作ったKentonなんで注目しています。実際レポジトリもCloudflareのorgにあります。

https://github.com/cloudflare/capnweb

また、Cloudflareのブログで紹介されています。こちらを見ればだいたいわかります。

https://blog.cloudflare.com/capnweb-javascript-rpc-library/

Cap'n Webの特徴は以下です。

  • サーバーとブラウザで動作する
  • JavaScriptのメソッド呼び出し、オブジェクト操作をそのままにRPC呼び出しができる
  • HTTP、WebSocket、postMessage()で動作する
  • メジャーなブラウザ、Cloudflare Workers、Deno、Node.jsをサポート(後述するHono AdapterだとBunもサポート)
  • 依存なしでminify+gzipして10kB
  • TypeScriptによる型サポート

ライブラリは以下のコマンドでインストールできます。

npm install capnweb

RPCであること

RPCであるということはRESTやGraphQLを置き換えるものとして使える可能性があります。特にサーバーサイド、クライアントサイドの実装をJavaScript/TypeScriptで統一したい場合に使えます。これから紹介する特徴をみると強力だと思うでしょう!

RPCのイメージ

RPCがどんなものか?は具体的なコード例と共に見ていくと理解が早いです。

RPCの実態となるサーバー側のAPIを定義します。これはcapnwebからimportしたRpcTargetをextendsしたクラスを作ることです。例えば、名前を受け取ってHello 名前という文字列を返すhelloを持ったクラスを定義できます。

src/api.ts
import { RpcTarget } from 'capnweb'

export class MyApiServer extends RpcTarget {
  hello(name: string) {
    return `Hello ${name}!`
  }
}

これをネイティブにクライアントサイド、つまりブラウザやCLIから呼び出せるわけです。例えば、WebSocketの時の実装だとこのようになります。

src/client.ts
import { newWebSocketRpcSession } from 'capnweb'
import type { MyApiServer } from './api'

const api = newWebSocketRpcSession<MyApiServer>('wss://localhost:8787/api')

const result = await api.hello('Yusuke')

console.log(result) // Hello Yusuke!

イメージとしてはこんな感じです。api.hello('Yusuke')がみそですね。これはまさにJavaScriptの関数呼び出しそのものです。

また、src/api.tsからMyApiServerの型を利用することで、型をつけれます。

Type

サーバーサイドの実装

サーバー側の実装はシンプルに済みます。RequestオブジェクトとMyApiServerのインスタンスをnewWorkersRpcResponseに渡すと良しなにハンドリングしてくれます。

src/server.ts
import { newWorkersRpcResponse } from 'capnweb'
import { MyApiServer } from './api'

export default {
  fetch(request: Request) {
    return newWorkersRpcResponse(request, new MyApiServer())
  }
}

これはCloudflare Workersの例ですが、書き方を変えるとDeno、Node.jsに対応させることができます。また、Hono Adapaterを使うとHonoっぽく書けます。

動かし方

今回はサーバーサイドはCloudflare Workersの開発環境であるWranglerを使って立ち上げます。

npx wrangler dev src/server.ts

そして、クライアント側はブラウザから呼び出すように実装すればいいのですが、面倒なので、コマンドから実行しましょう。今回はtsxを使います。

tsx src/client.ts

2種類の呼び出し

APIは2種類の呼び出し方・呼び出され方があります。バッチとWebSocketです。どちらであってもサーバーの実装は変えなくてよいです。

バッチ

バッチはHTTPのリクエストになります。実装ではnewHttpBatchRpcSessionを使います。

src/client-batch.ts
import type { MyApiServer } from './api'
import { newHttpBatchRpcSession } from 'capnweb'

const batch = newHttpBatchRpcSession<MyApiServer>('http://localhost:8787/api')

const result = await batch.hello('Yusuke')

console.log(result) // Hello Yusuke!

これがバッチ呼び出しです。先ほど紹介したのがWebSocketの例なのですが、ほとんど同じです。で、ここからが面白いです。以下の例を見てください。

src/client-batch.ts
const batch = newHttpBatchRpcSession<MyApiServer>('http://localhost:8787/api')

const promise1 = batch.hello('Yusuke')
const promise2 = batch.hello('Syumai')

// ここで初めてリクエストが飛ぶ!
const [result1, result2] = await Promise.all([promise1, promise2])

console.log(result1) // Hello Yusuke!
console.log(result2) // Hello Syumai!

api.hello()の返り値をawaitせずにPromiseのまま変数に入れています。そして2つのプロミスをPromise.allで最後に実行しています。そうするとクライアント側の出力はこうなります。

Client Output

これは当然ですね。ではサーバーのログはどうなっているでしょうか。

Server Log

/apiへのPOSTリクエストが1度だけ行われています。2度関数呼び出しをしたにもかかわらずです!これがバッチたるゆえんです。つまり、Promiseをもらった時点ではリクエストは飛ばず、awaitで解決された時にリクエストが飛びます。

パイプライン

もっと面白いものを見せましょう。あるメソッドを呼び出し、その返り値をまた他のメソッドに渡すということが、先ほどと同じようにひとつのネットワークラウンドトリップでできます。

APIを以下のように定義します。

import { RpcTarget } from 'capnweb'

export class MyApiServer extends RpcTarget {
  getMyName() {
    return 'Yusuke'
  }

  hello(name: string) {
    return `Hello ${name}!`
  }
}

これを利用するには以下のように書けます。

import { newHttpBatchRpcSession } from 'capnweb'

const batch = newHttpBatchRpcSession<MyApiServer>('http://localhost:8787/api')

const namePromise = batch.getMyName()
const result = await batch.hello(namePromise) // ここで初めてリクエストが飛ぶ!

console.log(result) // Hello Yusuke!

namePromiseはPromiseなのに注目してください。それをそのままbatch.hello()に渡しています。これを実行してみます。

Client Output

クライアントは当然の結果なのですが、サーバーのログを見てみます。

Server Log

HTTPリクエストは1度だけです!これは非常に面白い。まずPromiseをメソッドに渡せるということです。APIは変えていません。batch.hello()の時にawaitして初めてリクエストが飛んでいます。これによりメソッドチェーンのパイプラインを作るみたいなことができます。で、実はCap'n Webで使われるPromiseは通常のものではなく、パイプライン処理をよしなにしてくれるJavaScriptのProxyになっています。

どちらにせよこれは以下の2つの点で面白いです。

  • JavaScriptのネイティブな関数呼び出し
  • パイプラインを作って1度のラウンドトリップで実行

map()

もっとすごいことをやってみましょう。以下のAPIを定義します。

src/api.ts
class User extends RpcTarget {
  name: string
  friends: User[]
  constructor(name: string) {
    super()
    this.name = name
    this.friends = []
  }
  getMessage() {
    return `I'm ${this.name}`
  }
  addFriend(name: string) {
    this.friends.push(new User(name))
  }
  listFriends() {
    return this.friends
  }
}

export class MyApiServer extends RpcTarget {
  createUser(name: string) {
    return new User(name)
  }
}

それを呼びだすコードは以下です。よく見てください。

src/client-batch.ts
import type { MyApiServer } from './api'
import { newHttpBatchRpcSession } from 'capnweb'

const batch = newHttpBatchRpcSession<MyApiServer>('http://localhost:8787/api')

const userPromise = batch.createUser('Yusuke')
userPromise.addFriend('Syumai')
userPromise.addFriend('Chimame')
userPromise.addFriend('CodeHex')

const messagesPromise = userPromise.listFriends().map((friend) => {
  return friend.getMessage()
})

// [ "I'm Syumai", "I'm Chimame", "I'm CodeHex" ]
console.log(await messagesPromise) // ここで初めてリクエストが飛ぶ!

この結果は以下になります。

Client Output

サーバーサイドのログです。

Server Log

こちらも先ほどと同じように1度だけのリクエストです。友達のリストを処理するために毎回ラウンドトリップが発生するのではなく、map()を活用することで、パイプラインを作っています。

この機能は既存のRESTやGraphQLに対して大きなアドバンテージになります。Web APIを作る際、リスト処理はしばしば面倒ですが、Cap'n WebではAPIを変えることなく、リスト処理を一つのリクエストにまとめることができるのです。

WebSocket

バッチともうひとつのWebSocketを使った呼び出しについてです。バッチとは違いWebSocketコネクションを貼りっぱなしになります。例えば以下のようなコードを書けます。APIとサーバーの実装は変える必要がありません。

src/client-websocket.ts
import type { MyApiServer } from './api'
import { newWebSocketRpcSession } from 'capnweb'

const api = newWebSocketRpcSession<MyApiServer>('ws://localhost:8787/api')

console.log(await api.hello('Yusuke'))

await new Promise((resolve) => setTimeout(resolve, 1000))

console.log(await api.hello('Syumai'))

これを実行すると「Hello Yusuke!」とまず表示され、1秒後に「Hello Syumai!」と表示されます。そしてプログラムは起動したままです。

Client Output

サーバーサイドはWebSocketのコネクションが一つだけです。

Server Log

これはチャット等リアルタイムで共有する系の実装には便利です。なにせJavaScriptのメソッド呼び出しが使えます。

Durable Objectsと一緒に使う

単純な実装だとステートが管理できません。そのためにいくつか実装方法がありますが、Cloudflare Workersだと強力です。JSPRCという仕組みでCap'n Webと同じRpcTargetというクラスオブジェクトがcloudflare:workersからexportされているのでがそれを、Cap'n Webのものと同じように使えます。そしてRpcTargetをextendsして作ったクラス内でDurable Objectsを保持すればステート管理できます。簡略化した図にするとこのようなものです。

Diagram

クライアント側ではAPI定義をTypeScriptの型としてだけ扱えばいいので、それがcloudflare:workersのものだとしても問題はありません。

これはつまりCap'n WebのWeb Socketクライアントからステート付きのオブジェクトにアクセスできるということで、非常に便利です。

既存のRPCとの比較

同じようなRPCを可能にするJavaScript/TypeScriptライブラリはいくつかあります。その代表的な一つがtRPCです。Hello Worldな実装をCap'n Webと比べると並べるとこのようになります。

Cap'n Web

tRPC

Cap'n Webの方がJavaScriptネイティブなAPIを書けるので親しみやすいですね。ただ、tRPCの方が値の検証をZodでしているので、同じような処理をCap'n Web実装にも付ける必要はありそうです。

また、HonoでもHono RPCという機能を提供しています。これはサーバーサイドで作った、いわばRESTのAPIのスペックをTypeScriptの型として書き出し、クライアントサイドでそれ読み込みfetchのラッパーで利用するというものです。そもそもコンセプトが違いますが、比べてみましょう。

Hono RPC

どうしてもCap'n Webと比べると冗長が記述になっています。ただ、Hono RPCの場合はただただHonoのアプリを作ればいいです。ですので場面によって使い分ければいいと思います。

実例

TanStack AIのとあるExampleで使われてて興味深いです。ステートの管理こそメモリ上ですが、チャットの実装で使われています。

https://github.com/TanStack/ai/tree/main/examples/ts-group-chat

Hono Adapter

Cap'n WebのHono Adapterを作りました!

https://github.com/honojs/middleware/tree/main/packages/capnweb

以下でインストールできます。

npm install @hono/capnweb

このように利用します。

import { Hono } from 'hono'
import { upgradeWebSocket } from 'hono/cloudflare-workers'
import { newRpcResponse } from '@hono/capnweb'
import { MyApiServer } from './my-api-server'

const app = new Hono()

app.all('/api', (c) => {
  return newRpcResponse(c, new MyApiServer(), {
    upgradeWebSocket,
  })
})

export default app

これはCloudflare Workersの場合ですが、似たようなインターフェースでNode.jsやDeno、Bunに対応します。ロジックや他のHonoのハンドラを変える必要はありません。

Node.js:

import { serve } from '@hono/node-server'
import { createNodeWebSocket } from '@hono/node-ws'
import { Hono } from 'hono'
import { newRpcResponse } from '@hono/capnweb'
import { MyApiServer } from './my-api-server'

const app = new Hono()

const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })

app.all('/api', (c) => {
  return newRpcResponse(c, new MyApiServer(), {
    upgradeWebSocket,
  })
})

const server = serve({
  port: 8787,
  fetch: app.fetch,
})

injectWebSocket(server)

Deno:

import { Hono } from 'hono'
import { upgradeWebSocket } from 'hono/deno'
import { newRpcResponse } from '@hono/capnweb'
import { MyApiServer } from './my-api-server.ts'

const app = new Hono()

app.all('/api', (c) => {
  return newRpcResponse(c, new MyApiServer(), {
    upgradeWebSocket,
  })
})

export default app

Bun:

import { Hono } from 'hono'
import { upgradeWebSocket, websocket } from 'hono/bun'
import { newRpcResponse } from '@hono/capnweb'
import { MyApiServer } from './my-api-server'

const app = new Hono()

app.all('/api', (c) => {
  return newRpcResponse(c, new MyApiServer(), {
    upgradeWebSocket,
  })
})

export default {
  fetch: app.fetch,
  port: 8787,
  websocket,
}

BunのWebSocketのAPIが特殊なので、Cap'n Web単体だと今のところ対応が難しいのですが、Hono AdapterだとHonoのWeb Socketアダプタがランタイムの差を吸収してくれるので可能になっています。

まとめ

ということでCap'n Webについて紹介してきました。「JavaScriptネイティブにパイプラインを1ラウンドトリップで呼び出しができる」というのは非常に魅力的ではないでしょうか。HonoのAdapterもあるのでぜひ使ってみてください。

Discussion