🔭

HonoでもDate型を返したい!!!【Superjson】

2023/05/07に公開

HonoのRPCモード

Honoはシンプルで軽量なTS/JSのWebフレームワークです。
https://hono.dev/

以前にも紹介したことがありますが、HonoにはRPC モードというtRPCのように型付きのクライアントを生成する機能があります。
https://zenn.dev/kosei28/articles/f4bac1ed2b64a7

しかし、普通に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関数を作ります。
ミドルウェアで、ContextjsonSメソッドを追加できたら良かったのですが、無理そうだったので関数にしました。

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