📡

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

2023/02/27に公開

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

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

サンプルコード

https://github.com/kosei28/hono-rpc-sveltekit

デモ

https://hono-rpc-sveltekit.pages.dev/

HonoをSvelteKitで使う

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

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

npm install hono

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

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

export const apiApp = new Hono();

const apiRoute = apiApp.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 { Hono } from 'hono';
import { apiApp } from '$lib/server';

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

    return resolve(event);
};

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

src/routes/+page.svelte
<script lang="ts">
    const helloPromise = getHello();

    async function getHello() {
        const res = await fetch('/api/hello');
        const data: { message: string } = await res.json();
        return data;
    }
</script>

<h2>/hello</h2>
{#await helloPromise}
    <p>Loading...</p>
{:then hello}
    <p>{hello.message}</p>
{:catch error}
    <p style="color: red">{error.message}</p>
{/await}

RPCモードを使う

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

サーバー側では、c.json()c.jsonT()に変更し、apiRouteの型をexportするだけです。

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

export const apiApp = new Hono();

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

export type ApiRoute = typeof apiRoute;

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

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

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

    const helloPromise = getHello();

    async function getHello() {
        const res = await client.hello.$get();
        const data = await res.json();
        return data;
    }
</script>

<h2>/hello</h2>
{#await helloPromise}
    <p>Loading...</p>
{:then hello}
    <p>{hello.message}</p>
{:catch error}
    <p style="color: red">{error.message}</p>
{/await}

Zodでバリデーションする

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

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

npm install zod @hono/zod-validator

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

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

export const apiApp = new Hono();

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

export type ApiRoute = typeof apiRoute;

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

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

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

    const helloPromise = getHello();

    async function getHello() {
        const res = await client.hello.$get({ query: { name: 'Hono' } });
        const data = await res.json();
        return data;
    }
</script>

<h2>/hello</h2>
{#await helloPromise}
    <p>Loading...</p>
{:then hello}
    <p>{hello.message}</p>
{:catch error}
    <p style="color: red">{error.message}</p>
{/await}

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

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

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

export const apiApp = new Hono();

const apiRoute = apiApp
    .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().toISOString()
        });
    });

export type ApiRoute = typeof apiRoute;

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

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

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

    const helloPromise = getHello();
    const currentDatePromise = getCurrentDate();

    async function getHello() {
        const res = await client.hello.$get({ query: { name: 'Hono' } });
        const data = await res.json();
        return data;
    }

    async function getCurrentDate() {
        const res = await client.currentDate.$get({});
        const data = await res.json();
        return {
            datetime: new Date(data.datetime)
        };
    }
</script>

<h2>/hello</h2>
{#await helloPromise}
    <p>Loading...</p>
{:then hello}
    <p>{hello.message}</p>
{:catch error}
    <p style="color: red">{error.message}</p>
{/await}

<h2>/currentDate</h2>
{#await currentDatePromise}
    <p>Loading...</p>
{:then currentDate}
    <p>{currentDate.datetime.toLocaleString()}</p>
{:catch error}
    <p style="color: red">{error.message}</p>
{/await}

ルートをGroupingする

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

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

export const apiApp = new Hono();

const bookApp = new Hono();

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

const apiRoute = apiApp
    .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().toISOString()
        });
    })
    .route('/book', bookApp);

export type ApiRoute = typeof apiRoute;

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

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

export const apiApp = new Hono();

const bookApp = new Hono();

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

const apiRoute = apiApp
    .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().toISOString()
        });
    })
    .route('/book', bookRoute);

export type ApiRoute = typeof apiRoute;
src/routes/+page.svelte
<script lang="ts">
    import type { ApiRoute } from '$lib/server';
    import { hc } from 'hono/client';

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

    const helloPromise = getHello();
    const currentDatePromise = getCurrentDate();
    const allBookPromise = getAllBook();

    async function getHello() {
        const res = await client.hello.$get({ query: { name: 'Hono' } });
        const data = await res.json();
        return data;
    }

    async function getCurrentDate() {
        const res = await client.currentDate.$get();
        const data = await res.json();
        return data;
    }
    
    async function getAllBook() {
        const res = await client.book.all.$get();
        const data = await res.json();
        return data;
    }
</script>

<h2>/hello</h2>
{#await helloPromise}
    <p>Loading...</p>
{:then hello}
    <p>{hello.message}</p>
{:catch error}
    <p style="color: red">{error.message}</p>
{/await}

<h2>/currentDate</h2>
{#await currentDatePromise}
    <p>Loading...</p>
{:then currentDate}
    <p>{currentDate.datetime.toLocaleString()}</p>
{:catch error}
    <p style="color: red">{error.message}</p>
{/await}

<h2>/book/all</h2>
{#await allBookPromise}
    <p>Loading...</p>
{:then allBook}
    <p>{allBook.message}</p>
{:catch error}
    <p style="color: red">{error.message}</p>
{/await}

まとめ

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

HonoのRPCモードは非常に便利で、SvelteKitとの相性も抜群ですね。
エンドポイントの型をクライアント側でも簡単に利用できるようになり、とても良い開発体験を得ることができます。

また、Honoのエンドポイント定義の柔軟性やZodでのバリデーション、Grouping機能も魅力的です。
SuperjsonやOpenAPIへの対応など、まだtRPCの方が機能は充実しているかなと思いますが、Honoのシンプルさはとても好きなので、今後の開発で積極的に採用していきたいと思います。

Discussion