💨

workerdのSocket APIでPostgreSQLに接続してみる

2023/05/10に公開

workerdのSocket APIが次のcompatibility_dateから有効になりそうなので近況を確認しました。

https://github.com/cloudflare/workerd/pull/601

前回の記事

https://zenn.dev/laiso/articles/02f49dbde85092

仕様

まずインターフェイスを確認します。

https://github.com/cloudflare/workerd/blob/f8f14f37551c15ac05afb8ad63c348996344c67c/src/cloudflare/sockets.ts

https://github.com/cloudflare/workerd/blob/f8f14f37551c15ac05afb8ad63c348996344c67c/src/cloudflare/internal/sockets.d.ts

使い方

https://github.com/cloudflare/workerd/blob/da20679103805bf9f1c82539b9f8f6ff42d2834d/samples/tcp/gopher.js#L25-L48

connect()で作成したSocketオブジェクトにreader/writerインスタンスからstreamingで読み書きするというデザインです。

ドラフト策定中のDirect Sockets APIを意識しつつ独自仕様ということが分かります。

https://wicg.github.io/direct-sockets/

DenoやWinterCGとの共通化を検討しているのでしょうか?

Web APIs(ブラウザ向けの仕様)なのでNode.jsのnet.Socketとも違いがありますね。

https
const net = require('node:net');
const client = net.createConnection({ port: 8124 }, () => {
  // 'connect' listener.
  console.log('connected to server!');
  client.write('world!\r\n');
});
client.on('data', (data) => {
  console.log(data.toString());
  client.end();
});
client.on('end', () => {
  console.log('disconnected from server');
});

つまりNode.js互換のORMの動作にはNode.js compatibilityでもnet.Socketが提供されている必要があるのだと思います。

方針はdiscussions/198で議論されているかと思いきや社内でスピード感を持ってどんどん実装しているみたいです。

https://github.com/cloudflare/workerd/discussions/198

環境構築

Socket APIをテストできる環境を構築します。

現在Cloudflare WorkersでSocket APIを有効にしても本番環境にデプロイできないようになっています[1]が、workerdをローカルで起動することでテストできます。

workerdはnpmに公開されているコンパイル済みのworkerdがありnpx workerdで気軽に試せるようなっています。

https://www.npmjs.com/package/workerd?activeTab=versions

ただ最新の1.20230419.0はcompatibility_flagsをオンにする必要があります。

npx wrangler generate cfw-tcp
cd cfw-tcp
wrangler.toml
compatibility_flags = ["tcp_sockets_support"]
npx wrangler dev --experimental-local


(gopher.jsを実行した結果)

--experimental-localを付けるとMiniflare 3系が使われて、内部でworkerdを呼び出しているのでtcp_sockets_supportが効くようになります。

Tips: 自分でworkerdコンパイルする

npmに公開されている以降のバージョンの機能を試したい時は自分でbazelビルドする必要があります。bazelを使っていると大規模なコードに感じるかもしれませんが、workerdは依存の少ないシンプルな10万行程度のC++コードベースなので、基本的にREADMEに従えば予想より簡単にできると思います。

https://github.com/cloudflare/workerd

ビルドしたらworkerdコマンドが以下のパスにできるので引数にconfigを渡してWorkerが起動できます。

./bazel-bin/src/workerd/server/workerd serve samples/hello-wasm/config.capnp
curl http://localhost:8080

HTTP接続

前回はgopherしたので今回はHTTPを喋ってみます。以下で neverssl.com:80 のレスポンスを取得できました。

これはすんなり実装できました。

import { connect } from 'cloudflare:sockets';

export default {
  async fetch(req, env) {
    const socket = connect({
      hostname: 'neverssl.com',
      port: 80
    });

    const writer = socket.writable.getWriter()
    const encoder = new TextEncoder();
    const encoded = encoder.encode("GET / HTTP/1.1\r\nHost: neverssl.com\r\n\r\n");
    await writer.write(encoded);

    const reader = socket.readable.getReader();
    const decoder = new TextDecoder();
    let response = "";
    while (true) {
      const res = await reader.read();
      if (res.done) {
        console.log("Stream done, socket connection has been closed.");
        break;
      }
      response += decoder.decode(res.value);
    }

    return new Response(response);
  }
}
curl http://localhost:8080                                                 
HTTP/1.1 200 OK
Date: Tue, 09 May 2023 15:51:15 GMT
Server: Apache/2.4.56 ()
Upgrade: h2,h2c
Connection: Upgrade
Last-Modified: Wed, 29 Jun 2022 00:23:33 GMT
ETag: "f79-5e28b29d38e93"
Accept-Ranges: bytes
Content-Length: 3961
Vary: Accept-Encoding
Content-Type: text/html; charset=UTF-8

<html>
	<head>
		<title>NeverSSL - Connecting ... </title>

PostgreSQL接続

HTTP接続はfetchで代用できるのでsocket接続でしかできなさそうな事としてPostgreSQLへの接続をやってみます。

TCP接続ができれば「Vercel PostgresがどうやってEdge RuntimeでORMとコネクションプールを使えるようにしているのか」で示したとうりネイティブドライバーでPrismaを利用することもできます。

今回は、wrangler generateから使えるTemplate: worker-postgresというテンプレートがあったのでそれを利用して改造していくことにします。

npx wrangler generate postgres worker-postgres

このプロジェクトはこんな構造になっています。

src/
├── deno
│   ├── LICENSE
│   ├── README.md
│   ├── buffer.d.ts
│   ├── buffer.js
│   ├── deferred.d.ts
│   ├── deferred.js
│   └── workers-override.ts
├── driver
│   └── postgres
│       ├── 62edfb469c0dbacd90273cf9a0d7a478.wasm
│       ├── LICENSE
│       ├── README.md
│       ├── edgeworkerizer.py
│       ├── index.d.ts
│       ├── index.js
│       └── postgres.js.deno
└── index.ts

特徴としてはdeno-postgresをなんとかして動かそうと変換しています。

https://deno-postgres.com/#/?id=deno-postgres

ビルドフロー全体は以下になっています。

  1. edgeworkerizer.py: Deno向けのClientクラスをWorker向けのJSに変換
    a. postgres.js.deno to index.js
  2. edgeworkerizer.py: 62edfb469c0dbacd90273cf9a0d7a478.wasmの呼び出しをindex.jsに埋め込む
  3. workers-override.tsの内容でClientクラス内で呼び出されるDenoのコードを上書きする
  4. 上記までの処理をesbuildでdist/index.mjsに書き出す
cd src/driver/postgres/
python3 edgeworkerizer.py postgres.js.deno > index.js
cd -
esbuild --watch --bundle --sourcemap --outfile=dist/index.mjs --minify --format=esm ./src/index.js --external:*.wasm --external:cloudflare:* --inject:./src/deno/workers-override.ts

エントリーポイントindex.tsは以下のように書きました。

index.ts
import { Client } from './driver/postgres';

export default {
	async fetch(request, env) {
		try {
			const client = new Client({
				user: 'postgres',
				database: 'postgres',
				hostname: 'POSTGRES_HOST',
				password: 'POSTGRES_PASSWORD',
				port: '5432',
				tls: {enabled: false}
			});
			await client.connect();

			const result = await client.queryObject`select * from Event;`;

			return new Response(JSON.stringify(result.rows));
		} catch (err) {
			return new Response((err as Error).message);
		}
	},
}

POSTGRES_HOSTは最初localhostで試しましたがコネクション確立すらがうまくいかなかったのでSupabaseのリモートホストを使いました。そしたらコネクション確立はできました(後述)

configは以下になります。esbuildの出力パスに加えて、参照するwasmファイルのパスも追加する必要があります。

using Workerd = import "/workerd/workerd.capnp";

const postgresExample :Workerd.Config = (
  services = [
    (name = "main", worker = .postgresWorker)
  ],
  compatibilityDate = "2023-02-28",
);

const postgresWorker :Workerd.Worker = (
  modules = [
    (name = "worker", esModule = embed "dist/index.mjs"),
    (name = "./62edfb469c0dbacd90273cf9a0d7a478.wasm", wasm = embed "dist/62edfb469c0dbacd90273cf9a0d7a478.wasm" )
  ]
);

既存の生成されたコードではCloudflare Tunnelのホストに接続するために、HTTP->WebSocketにupgradeされる処置が入っています。

ここをworkerdのSocket APIに書き換えることで何とかなるのでは? という予測です。

まずはworkers-override.tsでDeno.connect()を実装している個所があるのでガッツリ削除して以下に置き換えます。

workers-override.ts
import { connect as cfConnect } from 'cloudflare:sockets';

export function connect(options: ConnectOptions): Promise<any> {
    return new Promise<Conn>((resolve, reject) => {
        const socket = cfConnect(options);
        resolve(socket);
    });
}

そしてDeno.connectの呼び出し先であるworkers-override.tsにある以下の個所を書き換えます

    async #createNonTlsConnection(options) {
        this.#conn = await Deno.connect(options);
-        this.#bufWriter = new BufWriter(this.#conn);
+        this.#bufWriter = new BufWriter(this.#conn.writable.getWriter());
-        this.#bufReader = new BufReader(this.#conn);
+        this.#bufReader = new BufReader(this.#conn.readable.getReader());
    }

テストしてみます。

curl http://localhost:8080 -v
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1   
> Host: localhost:8080
> User-Agent: curl/8.0.1
> Accept: */*      
>                          
< HTTP/1.1 200 OK
< Content-Length: 13
< Content-Type: text/plain;charset=UTF-8                                                      
<                  
* Connection #0 to host localhost left intact
negative read  

negative readとだけ出力されて失敗しました。

これはClient内に定義されているassertでした、単純にこれを無視しても解決せず、そももサーバーからの応答の読み込みが不正であるためにこれにひっかかっているようでした。

これ以上のデバッグはサーバーとどういう通信をしているのかを監視する必要があるので一旦退却です。

脚注
  1. デプロイすると'The compatibility flag tcp_sockets_support is experimental and cannot yet be used in Workers deployed to Cloudflare. [code: 10021]'と怒られます ↩︎

Discussion