HonoでもDate型を返したい!!!【Superjson】
HonoのRPCモード
Honoはシンプルで軽量なTS/JSのWebフレームワークです。
以前にも紹介したことがありますが、HonoにはRPC モードというtRPCのように型付きのクライアントを生成する機能があります。
しかし、普通にJSONでやり取りするためDate型などはそのまま使うことができず、stringなどに変換する必要があります。
せっかくエンドポイントの型を使えるなら、Date型もそのまま使いたいのですが、なんとtRPCではそれが可能になっています。
tRPCでできるなら、Honoでもできるはず・・・
ということで、やってみたら一応できたので、共有したいと思います。
Superjson
tRPCでは、Transformerという機能でSuperjsonというライブラリを使うことによって、Date型などをそのまま使うことを可能にしています。
Superjsonでは、型などのmeta情報をデータ本体とは別に持っておくことで、Date型やBigInt型などをJSONからオブジェクトにパースするときに復元できるようにしています。
HonoでSuperjsonを使う
インストール
まずはHonoとSuperjsonをインストールします。
npm install hono superjson
Superjsonのレスポンスを返せるようにする
HonoのRPCモードでは、次のようにjsonT
メソッドを使ってクライアントから型を取得できるようにレスポンスを生成します。
import { Hono } from 'hono';
export const app = new Hono();
const route = app.get('/currentDate', (c) => {
return c.jsonT({
datetime: new Date().toISOString()
});
});
export type AppType = typeof route;
同じようにSuperjsonのレスポンスを返せるjsonS
関数を作ります。
ミドルウェアで、Context
にjsonS
メソッドを追加できたら良かったのですが、無理そうだったので関数にしました。
import type { Context } from 'hono';
import type { StatusCode } from 'hono/utils/http-status';
import type { TypedResponse } from 'hono/dist/types/types';
import superjson from 'superjson';
import type { SuperJSONValue } from 'superjson/dist/types';
type HeaderRecord = Record<string, string | string[]>;
const getSuperjsonResponse = <T = object>(
c: Context,
object: T,
arg?: StatusCode | RequestInit,
headers?: HeaderRecord
) => {
const body = superjson.stringify(object);
c.header('content-type', 'application/json; charset=UTF-8');
c.header('x-superjson', 'true');
return typeof arg === 'number' ? c.newResponse(body, arg, headers) : c.newResponse(body, arg);
};
export const jsonS = <T>(
c: Context,
object: T extends SuperJSONValue ? T : SuperJSONValue,
arg?: StatusCode | RequestInit,
headers?: HeaderRecord
): TypedResponse<T extends SuperJSONValue ? (SuperJSONValue extends T ? never : T) : never> => {
return {
response:
typeof arg === 'number'
? getSuperjsonResponse(c, object, arg, headers)
: getSuperjsonResponse(c, object, arg),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: object as any,
format: 'json'
};
};
基本的には、jsonT
メソッドと同じような実装になっています。
違いを挙げると以下のようになります。
-
Context
を引数で受け取る -
JSONValue
の代わりにSuperJSONValue
を使う -
JSON.stringify
の代わりにsuperjson.stringify
を使う -
x-superjson
ヘッダーを付与する
jsonS
関数ではなくjsonT
メソッドを使った場合、Superjsonではパースできないため、jsonS
関数を使ったことをクライアントに示すために、x-superjson
ヘッダーを付与しています。
この関数を使って、次のようにレスポンスを返すことができます。
const route = app.get('/currentDate', (c) => {
return jsonS(c, {
datetime: new Date()
});
});
クライアント側で Superjson を使う
HonoのRPCモードでは、hc
関数でクライアントを生成することができます。
jsonS
を使ったエンドポイントではSuperJSONValue
の型が付与されているため、既に次のようにDate型を得ることができます。
import { hc } from 'hono/client';
const client = hc<AppType>('http://localhost:8787/');
const res = await client.currentDate.$get();
const data: { datetime: Date } = await res.json();
しかし、superjson.stringify
を使っているため、実際にawait res.json()
で得られるデータは以下のようになっています。
const data = {
json: {
datetime: '2023-05-07T06:21:15.659Z'
},
meta: {
values: { datetime: ['Date'] }
}
};
ここでは、Superjsonを返すエンドポイントでも正しくパースできるように、hc
をラップしたhcs
というクライアントを作ることにします。
import { hc } from 'hono/client';
import type { Hono } from 'hono';
import type { UnionToIntersection } from 'hono/utils/types';
import type { Callback, Client, RequestOptions } from 'hono/dist/types/client/types';
import superjson from 'superjson';
const createProxy = (callback: Callback, path: string[]) => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
const proxy: unknown = new Proxy(() => {}, {
get(_obj, key) {
if (typeof key !== 'string') return undefined;
return createProxy(callback, [...path, key]);
},
apply(_1, _2, args) {
return callback({
path,
args
});
}
});
return proxy;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const hcs = <T extends Hono<any, any, any>>(baseUrl: string, options?: RequestOptions) =>
createProxy(async ({ path, args }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let client: any = hc(baseUrl, options);
for (const part of path) {
client = client[part];
}
const res: Response = await client(...args);
if (res.headers.get('x-superjson') === 'true') {
res.json = async () => {
const text = await res.text();
return superjson.parse(text);
};
}
return res;
}, []) as UnionToIntersection<Client<T>>;
ProxyをProxyでラップしてるのでちょっと気持ち悪いですが、とりあえずは問題なく動きます。
hc
でエンドポイントからレスポンスを取得し、x-superjson
ヘッダーを見て、res.json
メソッドをsuperjson.parse
を使うようにオーバーライドしています。
hcs
の型はhc
と全く同じなので、hc
と同じように使うことができます。
const client = hcs<AppType>('http://localhost:8787/');
const res = await client.currentDate.$get();
const data: { datetime: Date } = await res.json();
これで、data
の中身も{ datetime: Date }
となり、型と一致したオブジェクトを得ることができました。
まとめ
Superjsonを使うことで、Date型などの通常のJSONでは表現できないデータを扱うことができるようになりました。
今回、SuperjsonをHonoで使えるようにしていく上で、ミドルウェアでContext
を書き換えられると、より便利になりそうだなと思いました。
tRPCは人気ですが、Honoでも同じくらいの開発体験を得られるようになってきているので、HonoのRPCモードももっと注目されるようになるといいなと思います。
Discussion