自分のためにつくっているが、まだいろいろ考えないといけないことがある。
ひとまず、どういうものなのかをここに記す。
まず、serverが受信、clientが送信とする。
基本的なsocket.ioのコードだと、client側は以下になる。
const socket = io("http://localhost:3000");
socket.emit("eventName", data);
emit時に、必ず存在するeventに、正しい型のdataを送りたい。
一応、socketの初期化時にname spaceごとに型を与えて推測するようにできる。
https://socket.io/docs/v4/typescript/
個人的には、trpcやts-restのように、自動生成される型安全な送信用のクライアントがほしい、と考えた。
サーバー側の実装を考える。
export type ServerAppRoute = {
data: z.ZodSchema;
};
export type ServerAppRouter = {
[key: string]: ServerAppRoute;
};
export const serverRouter = {
comment: {
data: z.object({
comment: z.string(),
}),
},
hello: {
data: z.object({
w: z.string(),
}),
},
hello2: {
data: z.object({
word: z.string(),
}),
},
} as const satisfies ServerAppRouter;
comment, hello, hello2がeventNameになる。
そして、dataという部分がzodSchemaで定義されている。
サーバー側の受信を基本通りに書くと、こうなる。
const io = new Server({
cors: {
origin: "*",
},
});
io.on("connection", (socket) => {
socket.on("comment", (data) => {});
socket.on("hello", (data) => {});
socket.on("hello2", (data) => {});
});
io.listen(3000);
このあたりを型安全かつ簡単にしたい。
clientから送信する部分を考える。
たとえば
const client = initClient(serverRouter, socket);
client.comment({ comment: "comment is hello" });
このようにclient.と押したときにcomment, hello, hello2がサジェストされ、comment({と書いたときにcomment: “”が類推される。そういうふうにつくりたい。
そのようなinitClientは以下のように実装する。
const getClientEmit = <T extends ServerAppRoute,>(
socket: Socket,
key: string,
) => {
return (data: z.infer<T["data"]>) => {
socket.emit("socket-io-rest-contract", key, data);
};
};
export const initClient = <T extends ServerAppRouter>(
contract: T,
socket: Socket,
) =>
pipe(
contract,
D.mapWithKey((key) => getClientEmit(socket, key.toString())),
) as {
[K in keyof T]: T[K]["data"] extends undefined
? () => void
: (data: z.infer<T[K]["data"]>) => void;
};
initClientはcontract, socketの2つを引数として受け取る。
contractはServerAppRouter型のジェネリクスである。
その後、objectのkey(comment, hello, hello2)でループしていき、getClientEmit関数へ通す。
そうすると、client.のサジェストでcomment, hello, hello2が表示されるようになる。
本当は、ネストしている可能性もあるので(comment.c1, comment.c2のように)、RouteなのかRouterなのかで判断して、再度関数を呼んだほうが良いだろう。
getClientEmitによって、あるデータを受け取って、socket.emitでsocket-io-rest-contractというeventNameで送信する。普通はeventNameがkeyになると思うが、keyをユニオン型として処理したいので、第一引数をkey, 第二引数をdateとすることにした。
最終的に、いまは無理やりasで[K in keyof T]などをして型をつけているが、これは本来、getClientEmit側でどうにかなるはず。ちょっとTypeScript力が足りていないのでサボっている部分である。
次に、サーバーの受信側を見てみよう。
io.on("connection", (socket) => {
socket.on("socket-io-rest-contract", (key, data) => {
const route = zodEnumFromObjKeys(serverRouter).parse(key);
match(route)
.with("comment", (input) => {
const props = serverRouter[input].data.parse(data);
console.log(props.comment);
})
.with("hello", (input) => {
const props = serverRouter[input].data.parse(data);
console.log(props.w);
})
.with("hello2", (input) => {
const props = serverRouter[input].data.parse(data);
console.log(props.word);
})
.exhaustive();
});
});
zodEnumFromObjKeys関数にserverRouterを渡すと、serverRouterのkeyのenumとなる。
(つまりcomment, hello, hello2)
このschemaによってkeyをparseし、routeの型を確定させる。
あとはts-patternのmatchによってkeyごとの処理を書く。
こうすることで、新しくルートをserverRouterに足したとしても処理の記述が漏れることはなくなる。
あんまりいけてないのが、serverRouter[input].data.parse(data)のように必ずパースをしないといけないところで、ここは要改善。
そういう感じで、socket.ioを型安全に使えるライブラリを考えている。
appRouterを作成し、それをループで処理して、あるzodSchemaのオブジェクトを引数とする関数を生やすことで、自作のrpc-clientがつくれる。
送信の処理部分をsocket.ioではなくaxiosなどでもつくれるだろう(ただ、それならtrpcとかts-restで良いよね、という話になるが……)。
axiosなどのときは、inputとreturnの2つの型を考える必要があるだろう。(socket.ioはcall_backを考えない限り、送りっぱなしなので。またserver側からclient側へemitするときは、べつのrouterを用意する必要がある。結局、行うのはだいたい一緒のことだ)
つくっているとなかなか面白い。