📡

HonoのRPCモードをSvelteKitで試してみた

2023/02/26に公開約9,800字

Honoのv3がリリースされ、RPCモードが使えるようになりました。
RPCモードを使うと、サーバーサイドのエンドポイントの型をクライアントでも利用できるようになります。
簡単に言うとtRPCみたいなやつです。
https://zenn.dev/yusukebe/articles/53713b41b906de#rpcモード

今回は、このHonoのRPCモードをSvelteKitで試してみたいと思います。

HonoをSvelteKitで使う

RPCモードを使う前に、まずはHonoをSvelteKitで使えるようにしていきます。

まず、Honoをインストールします。

npm install hono

次にエンドポイントを書きます。

src/lib/hono.ts
import { Hono } from 'hono';

export const app = new Hono();

app.get(
    '/hello',
    (c) => {
        return c.json({
            message: `Hello!`
        });
    }
);

エンドポイントができたので、/apiでアクセスできるようにします。
SvelteKitではhooks.server.tsでリクエストをハンドルすることができるので、/apiへのリクエストをHonoで処理するようにします。

src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { app } from '$lib/hono';
import { Hono } from 'hono';

export const handle: Handle = async ({ event, resolve }) => {
    if (event.url.pathname.startsWith('/api')) {
        return await new Hono().route('/api', app).handleEvent(event);
    }

    return resolve(event);
};

クライアント側で、エンドポイントにアクセスしてみます。

src/routes/+page.svelte
<script lang="ts">
    import { onMount } from 'svelte';

    let helloData: { message: string } | null = null;

    onMount(async () => {
        const helloRes = await fetch('/api/hello');
        helloData = await helloRes.json();
    });
</script>

{#if helloData === null}
    <div>loading</div>
{:else}
    <div>{helloData.message}</div>
{/if}

RPCモードを使う

SvelteKitでHonoが使えるようになったので、本題のRPCモードを試していきます。

エンドポイント側では、c.json()c.jsonT()に変更し、app.get()の返り値の型をexportするだけです。

src/lib/hono.ts
import { Hono } from 'hono';

export const app = new Hono();

const route = app.get(
    '/hello',
    (c) => {
        return c.jsonT({
            message: `Hello!`
        });
    }
);

export type AppType = typeof route;

クライアント側では、hcというHonoのクライアントを使います。
hc()にエンドポイント側でexportしたAppTypeをジェネリクスとして渡すと、しっかりと型が効いた状態になります。

src/routes/+page.svelte
<script lang="ts">
    import type { AppType } from '$lib/hono';
    import { hc, type InferResponseType } from 'hono/client';
    import { onMount } from 'svelte';

    const client = hc<AppType>('/api');

    type HelloData = InferResponseType<typeof client.hello.$get>;

    let helloData: HelloData | null = null;

    onMount(async () => {
        const helloRes = await client.hello.$get();
        helloData = await helloRes.json();
    });
</script>

{#if helloData === null}
    <div>loading</div>
{:else}
    <div>{helloData.message}</div>
{/if}

Zodでバリデーションする

次に、クエリパラメーターをZodでバリデーションできるようにしていきます。

まずは、必要なライブラリをインストールします。

npm install zod @hono/zod-validator

エンドポイント側でクエリパラメータを定義します。

src/lib/hono.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';

export const app = new Hono();

const route = app.get(
    '/hello',
    zValidator(
        'query',
        z.object({
            name: z.string()
        })
    ),
    (c) => {
        const { name } = c.req.valid('query');
        return c.jsonT({
            message: `Hello! ${name}`
        });
    }
);

export type AppType = typeof route;

クライアント側で$get()の引数に、型が効いた状態でクエリパラメータを指定できるようになります。

src/routes/+page.svelte
<script lang="ts">
    import type { AppType } from '$lib/hono';
    import { hc, type InferResponseType } from 'hono/client';
    import { onMount } from 'svelte';

    const client = hc<AppType>('/api');

    type HelloData = InferResponseType<typeof client.hello.$get>;

    let helloData: HelloData | null = null;

    onMount(async () => {
        const helloRes = await client.hello.$get({ query: { name: 'Hono' } });
        helloData = await helloRes.json();
    });
</script>

{#if helloData === null}
    <div>loading</div>
{:else}
    <div>{helloData.message}</div>
{/if}

複数のエンドポイントに対応する

Honoでは複数のエンドポイントを定義するとき、app.get()app.post()などを複数書いていくことが多いと思います。
しかし、この書き方では各エンドポイントの型をまとめて取得することができません。
型をまとめて取得できるようにするには、エンドポイントをメソッドチェーンで定義します。

src/lib/hono.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';

export const app = new Hono();

const route = app
    .get(
        '/hello',
        zValidator(
            'query',
            z.object({
                name: z.string()
            })
        ),
        (c) => {
            const { name } = c.req.valid('query');
            return c.jsonT({
                message: `Hello! ${name}`
            });
        }
    )
    .get('/currentDate', (c) => {
        return c.jsonT({
            datetime: new Date()
        });
    });

export type AppType = typeof route;

こうすることで、クライアント側からも複数のエンドポイントにアクセスできるようになります。

src/routes/+page.svelte
<script lang="ts">
    import type { AppType } from '$lib/hono';
    import { hc, type InferResponseType } from 'hono/client';
    import { onMount } from 'svelte';

    const client = hc<AppType>('/api');

    type HelloData = InferResponseType<typeof client.hello.$get>;
    type CurrentDateData = InferResponseType<typeof client.currentDate.$get>;

    let helloData: HelloData | null = null;
    let currentDateData: CurrentDateData | null = null;

    onMount(async () => {
        const helloRes = await client.hello.$get({ query: { name: 'Hono' } });
        helloData = await helloRes.json();

        const currentDateRes = await client.currentDate.$get({});
        currentDateData = await currentDateRes.json();
        currentDateData.datetime = new Date(currentDateData.datetime);
    });
</script>

{#if helloData === null || currentDateData === null}
    <div>loading</div>
{:else}
    <div>{helloData.message}</div>
    <div>{currentDateData.datetime}</div>
{/if}

Date型は文字列になる

新しく定義したエンドポイントではDate型を返していますが、注意しなければならないのは一度JSONにしているということです。
JSONはDate型に対応していないため文字列になってしまいます。
hcで得られる型はDateのままなので、型と実態が一致しないということになります。

幸いDateのコンストラクタの引数には文字列もDate型も入れることができるため、以下のように新しくDateのインスタンスを作れば型エラーを出さずに、型と実態を一致させることができます。

currentDateData.datetime = new Date(currentDateData.datetime);

しかし、これを書き忘れても型エラーにはならないため、根本的な解決にはなりません。
hc側でDate型をstring型に変換するようにするか、superjsonなどに対応する必要があるかなと思います。

それまでは、API側でDate型を返すときはtoISOString()メソッドで明示的に文字列に変換してから返すようにしたほうが良いかもしれません。

ルートをGroupingする

Honoのインスタンスを新しく作ってroute()メソッドに渡すことでGroupingすることができます。

src/lib/hono.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';

export const app = new Hono();

const book = new Hono();

book.get('/all', (c) => {
    return c.jsonT({
        message: `All Books`
    });
});

const route = app
    .get(
        '/hello',
        zValidator(
            'query',
            z.object({
                name: z.string()
            })
        ),
        (c) => {
            const { name } = c.req.valid('query');
            return c.jsonT({
                message: `Hello! ${name}`
            });
        }
    )
    .get('/currentDate', (c) => {
        return c.jsonT({
            datetime: new Date()
        });
    })
    .route('/book', book);

export type AppType = typeof route;

しかし、これではGroupingしたルートの型を取得することができません。
そこで、route()メソッドにbook.get()の返り値を渡すことで、Groupingをしてもしっかりと型を共有できるようになります。

src/lib/hono.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';

export const app = new Hono();

const book = new Hono();

const bookRoute = book.get('/all', (c) => {
    return c.jsonT({
        message: `All Books`
    });
});

const route = app
    .get(
        '/hello',
        zValidator(
            'query',
            z.object({
                name: z.string()
            })
        ),
        (c) => {
            const { name } = c.req.valid('query');
            return c.jsonT({
                message: `Hello! ${name}`
            });
        }
    )
    .get('/currentDate', (c) => {
        return c.jsonT({
            datetime: new Date()
        });
    })
    .route('/book', bookRoute);

export type AppType = typeof route;
src/routes/+page.svelte
<script lang="ts">
    import type { AppType } from '$lib/hono';
    import { hc, type InferResponseType } from 'hono/client';
    import { onMount } from 'svelte';

    const client = hc<AppType>('/api');

    type HelloData = InferResponseType<typeof client.hello.$get>;
    type CurrentDateData = InferResponseType<typeof client.currentDate.$get>;
    type AllBookData = InferResponseType<typeof client.book.all.$get>;

    let helloData: HelloData | null = null;
    let currentDateData: CurrentDateData | null = null;
    let allBookData: AllBookData | null = null;

    onMount(async () => {
        const helloRes = await client.hello.$get({ query: { name: 'Hono' } });
        helloData = await helloRes.json();

        const currentDateRes = await client.currentDate.$get();
        currentDateData = await currentDateRes.json();
        currentDateData.datetime = new Date(currentDateData.datetime);

        const allBookRes = await client.book.all.$get();
        allBookData = await allBookRes.json();
    });
</script>

{#if helloData === null || currentDateData === null || allBookData === null}
    <div>loading</div>
{:else}
    <div>{helloData.message}</div>
    <div>{currentDateData.datetime}</div>
    <div>{allBookData.message}</div>
{/if}

まとめ

今回は、新しく出たHonoのRPCモードを試してみました。

とても面白い試みで、普通に使いやすいと感じましたが、まだ登場したばかりということもあってか、tRPCの方が機能的には優れているのかなと思います。

tRPCをHonoで使うこともできるようなので、また今度試してみたいと思います。
https://github.com/honojs/middleware/tree/main/packages/trpc-server

今すぐ実用するのは難しいかもしれませんが、簡単なアプリなら作れますし、Hono自体も応援しているので、色々と試しながら今後に期待したいです。

Discussion

ログインするとコメントできます