Deno で tRPC、JSON-RPC やってみる

先日、Node 学園 #40 を見てました。
その中で、 フロントエンドでも gRPC が使いやすくなる connect-web について という LT があり、tRPC に触れていました。
本題とは別に tRPC に興味を持ったので、試してみます。

参考

tRCP 導入

tRCP は、エンドツーエンドの型安全な API を作成を目的としたライブラリだそうです。

サーバー側

server 側が次の実装になります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import * as trpc from "https://esm.sh/v89/@trpc/server@10.0.0-alpha.22";
import { fetchRequestHandler } from "https://esm.sh/v89/@trpc/server@10.0.0-alpha.22/adapters/fetch";
import { serve } from "https://deno.land/std@0.154.0/http/server.ts";

const appRouter = trpc.router().query("get-message", {
resolve(req: Request) {
console.log(req);
const data = { message: "hello world" };
return data;
},
});

export type AppRouter = typeof appRouter;

async function trcpHandler(req: Request) {
console.log(req);
const res = await fetchRequestHandler({
endpoint: "/",
req,
router: appRouter,
});
return new Response(res.body, {
headers: res.headers,
status: res.status,
});
}

serve(trcpHandler);

クライアント側

client 側が次の実装になります。

client.ts
1
2
3
4
5
6
7
8
9
10
11
// サーバー側で定義されたものを使う
import { AppRouter } from "./server.ts";
import { createTRPCClient } from "https://esm.sh/v89/@trpc/client@10.0.0-alpha.22";

export const trpc = createTRPCClient<AppRouter>({
url: "http://localhost:8000/",
});

const res = await trpc.query("get-message");

console.log(res);

動作させてみる

2 つコンソールを開いて、動かしていく。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ deno run -A server.ts
Listening on http://localhost:8000/
Request {
bodyUsed: false,
headers: Headers {
accept: "*/*",
"accept-encoding": "gzip, br",
"accept-language": "*",
"content-type": "application/json",
host: "localhost:8000",
"user-agent": "Deno/1.25.0"
},
method: "GET",
redirect: "follow",
url: "http://localhost:8000//get-message?batch=1&input=%7B%7D"
}
{
type: "query",
ctx: undefined,
path: "get-message",
rawInput: undefined,
input: undefined
}
1
2
$ deno run -A clients.ts
{ message: "hello world" }

クライアントから tRPC の呼び出しをするとサーバー側が動きます。
createTRPCClient をクライアント側で生成してアクセスするように実装がされています。
ただ実態を見ると http://localhost:8000//get-message?batch=1&input=%7B%7D にアクセスしている形になっています。

面白いのは、http のリクエストであることが隠ぺいされていてただの関数呼び出しのように使える点。
サーバー側の定義をクライアント側のジェネリクスにして利用することで、エンドツーエンドの型安全を実現するんだそうな。

ちょっと掘る

例えば、次の実装のように定義にないものを呼び出してみる。

1
2
3
4
5
6
7
8
9
10
import { AppRouter } from "./server.ts";
import { createTRPCClient } from "https://esm.sh/v89/@trpc/client@10.0.0-alpha.22";

export const trpc = createTRPCClient<AppRouter>({
url: "http://localhost:8000/",
});

const res = await trpc.query("get-message-next");

console.log(res);

次のようにエラーになる、ステータスなど取れない。

1
2
3
4
5
6
$ deno run -A clients.ts
error: Uncaught (in promise) TRPCClientError: No "query"-procedure on path "get-message-next"
at Function.value (https://esm.sh/v89/@trpc/client@10.0.0-alpha.22/deno/client.js:2:2402)
at Se (https://esm.sh/v89/@trpc/client@10.0.0-alpha.22/deno/client.js:7:6279)
at https://esm.sh/v89/@trpc/client@10.0.0-alpha.22/deno/client.js:7:7822
Caused by: null

ということになる。

対策は 3 つあり、以下のようにそれぞれ実装できる。

1
2
3
4
5
6
7
8
9
10
11
// 即時関数パターン
const res = await(async () => {
try {
return await trpc.query("get-message-next");
} catch (e) {
console.log(e);
return { message: null };
}
})();

console.log(res);
1
2
3
4
5
6
7
8
9
// Promise パターン
const res = await new Promise((resolve) =>
resolve(trpc.query("get-message-next"))
).catch((e) => {
console.log(e);
return { message: null };
});

console.log(res);
1
2
3
4
5
6
7
8
9
10
11
12
13
// 関数定義するパターン(書くほどでもない)
async function getMessageNext() {
try {
return await trpc.query("get-message-next");
} catch (e) {
console.log(e);
return { message: null };
}
}

const res = await getMessageNext();

console.log(res);

いずれも、次のようにエラー表示する。

1
2
3
4
5
TRPCClientError: No "query"-procedure on path "get-message-next"
at Function.value (https://esm.sh/v89/@trpc/client@10.0.0-alpha.22/deno/client.js:2:2402)
at Se (https://esm.sh/v89/@trpc/client@10.0.0-alpha.22/deno/client.js:7:6279)
at https://esm.sh/v89/@trpc/client@10.0.0-alpha.22/deno/client.js:7:7822
{ message: null }

型の恩恵を受けてれば、こういうことは起きないはずであるものの、どうも推論が上手くいっていなかったのであえてやってみた。

とここまで試して続けようとしたものの、適切な動作をしているのか判断できない部分があり、ここまでで打ち切り。
また別途動かしてみたい。

いったん断念したが、後日ソースコードを追いながら調べていたら、動作の確認が取れたのでもう少し書き進めてみます。(9/16更新)

もっと掘る

context

動作が取れなかったのがこのcontextの部分。
ドキュメントに書いていないことが有るので、ここでは記載しておきたいところ。

サーバー側

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import * as trpc from "https://esm.sh/v89/@trpc/server@10.0.0-alpha.22";
import { fetchRequestHandler } from "https://esm.sh/v89/@trpc/server@10.0.0-alpha.22/adapters/fetch";
import { serve } from "https://deno.land/std@0.154.0/http/server.ts";

import * as trpcNext from "https://esm.sh/v89/@trpc/server@10.0.0-alpha.22/adapters/next";

const users = [
{ id: "user1", token: "AAAAAAAA" },
{ id: "user2", token: "BBBBBBBB" },
];

const createContext = (opts?: trpcNext.CreateNextContextOptions) => {
const authorization = opts?.req?.headers?.get("authorization");
if (typeof authorization !== "string") {
throw new trpc.TRPCError({ code: "BAD_REQUEST" });
}

const user = users.find((u) => u.token === authorization);

if (!user) {
throw new trpc.TRPCError({ code: "UNAUTHORIZED" });
}

return { user };
};
type Context = trpc.inferAsyncReturnType<typeof createContext>;

const appRouter = trpc
.router<Context>()
.query("get-message", {
resolve(req) {
const data = { message: "hello world", user: req.ctx.user };
return data;
},
});

export type AppRouter = typeof appRouter;

async function trcpHandler(req: Request) {
const res = await fetchRequestHandler({
endpoint: "/",
req,
router: appRouter,
createContext, // <= context の説明のドキュメントにないが、createContext を設定する必要がある
});
return new Response(res.body, {
headers: res.headers,
status: res.status,
});
}

serve(trcpHandler);

クライアント側

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { AppRouter } from "./server.ts";
import { createTRPCClient } from "https://esm.sh/v89/@trpc/client@10.0.0-alpha.22";

export const trpc = createTRPCClient<AppRouter>({
url: "http://localhost:8000/",
headers: {
authorization: "BBBBBBBB", //実際は、こんな簡単なものは渡さないだろうけども
},
});

const res = await trpc.query("get-message");

console.log(res);
// => { message: "hello world", user: { id: "user2", token: "BBBBBBBB" } }

リクエストヘッダーについている値をcontextに詰めて設定することができる。
今回はパラメーターを引き与えられない時、contextの中でエラーを返しました。
エラーについては、次に触れる middleware で返すほうが適切かもしれません。

middleware

先の context を追加した処理に middleware を付け足してみます。

今回はサーバー側のみです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import * as trpc from "https://esm.sh/v89/@trpc/server@10.0.0-alpha.22";
import { fetchRequestHandler } from "https://esm.sh/v89/@trpc/server@10.0.0-alpha.22/adapters/fetch";
import { serve } from "https://deno.land/std@0.154.0/http/server.ts";

import * as trpcNext from "https://esm.sh/v89/@trpc/server@10.0.0-alpha.22/adapters/next";

const users = [
{ id: "user1", token: "AAAAAAAA" },
{ id: "user2", token: "BBBBBBBB" },
];

const createContext = (opts?: trpcNext.CreateNextContextOptions) => {
const authorization = opts?.req?.headers?.get("authorization");
if (typeof authorization !== "string") {
throw new trpc.TRPCError({ code: "BAD_REQUEST" });
}

const user = users.find((u) => u.token === authorization);

return { user };
};
type Context = trpc.inferAsyncReturnType<typeof createContext>;

const appRouter = trpc
.router<Context>()
.middleware(async ({ ctx, next }) => {
// 一部のエラーハンドリングをmiddlewareに移設
if (!ctx.user) throw new trpc.TRPCError({ code: "UNAUTHORIZED" });

const result = await next();

return result;
})
.query("get-message", {
resolve(req) {
const data = { message: "hello world", user: req.ctx.user };
return data;
},
});

export type AppRouter = typeof appRouter;

async function trcpHandler(req: Request) {
const res = await fetchRequestHandler({
endpoint: "/",
req,
router: appRouter,
createContext, // <= context の説明のドキュメントにないが、createContext を設定する必要がある
});
return new Response(res.body, {
headers: res.headers,
status: res.status,
});
}

serve(trcpHandler);

呼び出すと context の時と同様の動作をする。
middleware は当たり前のことながら複数個並べて(重ねて?)使うこともできました。
すごくイイですね。

JSON-RPC - gentle_rpc を触る

打ち切ったので他に試せるものはないか、そして WEB サーバーと組み合わせ出来そうなものを探すと良さそうなものを見つけました。

timonson/gentle_rpc

tRCP にあった型定義周りの便利さみたいなものはないのですが、わりとイイ感じです。
(サンプルから欲しかったところを抜粋。)

server.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { serve } from "https://deno.land/std@0.154.0/http/server.ts";
import { respond } from "https://deno.land/x/gentle_rpc/mod.ts";

const rpcMethods = {
sayHello: ([w]: unknown[]): string => {
return `Hello ${w}`;
},
};

export type RpcMethods = typeof rpcMethods;

serve(async (req) => {
return await respond(rpcMethods, req);
});
client.ts
1
2
3
4
5
6
import { createRemote } from "https://deno.land/x/gentle_rpc/mod.ts";

const remote = createRemote("http://localhost:8000");
const greeting = await remote.call("sayHello", ["World"]);

console.log(greeting);

クライアント側から、Cookie を含めてリクエストが飛ぶのも別途確認しました。
内部的には fetch が動いていますし、そういう動きになるでしょう。
json-rpc としては Cookie の操作はできないですが、認証部分だけ切り出して別途 API で処理すると割り切ってしまえば、これはこれで使いやすそうです。

とはいえ、tRCP の方を触っていると型での制約が欲しくなる感じはあります。


今回は、tRCP と 当初予定に無かった JSON-RPC を触ってみました。
実は、gentle_rpc に型で制約をいれる改修を作ってみていたんですが、呼び出したい対象の関数名は制約できても、引数がどうにもできませんでした。

力不足と型パズルへの敗北ですね。

では。