0
0

socket.ioを型安全に使えるライブラリを考えている

Posted at

自分のためにつくっているが、まだいろいろ考えないといけないことがある。

ひとまず、どういうものなのかをここに記す。

まず、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を用意する必要がある。結局、行うのはだいたい一緒のことだ)

つくっているとなかなか面白い。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0