@ryoppippi

Hono RPCとSvelteKitの併用について

15 Jun 2023 ・ 7 min read


SvelteKit の​ Endpoint に​おける​型安全を​めぐる​考察

SvelteKit で​ Endpoint を​書く​場合、​現行の​実装では​それを​呼び出すためには​ fetch 関数を​用いる​ことになる。

https://kit.svelte.jp/docs/routing#server

fetch 関数を​用いると​いう​ことは​つまり、​Endpoint の​ URL や​ request 時の​引数を​渡す部分は​型安全が​担保されない。 その​ため、​個人的には​「標準の​方法で​ Endpoint を​設計+fetch」は​なるだけ​使いたくないなと​思っている。

この​問題を​解決する​ライブラリの​候補は​以下の​通り

  • tRPC
  • Hono RPC
  • Garph + GQty

どれを​使うべきかは​以下の​スレッドを​読んでいただきたい。

https://twitter.com/ryoppippi/status/1664258993118740480 https://twitter.com/ryoppippi/status/1664259029227524097 https://twitter.com/ryoppippi/status/1664259247079653379

で、​JS/TS で​完結する​場合は​ tRPC が​筋が​いいのだが、​他の​言語から​叩くとなると​ Garph​(GraphQL)も​しくは​ Hono​(Rest)が​候補に​上がる。 Garph に​関しては​試した​ところ、​まだ​ GQty の​実装が​成熟していないので​今後に​期待かなと​いう​ところ。 (つまり​ GraphQL Schema は​定義が​できるんだが、​tRPC like に​呼び出す部​分が​まだ​) https://github.com/ryoppippi/garph-sveltekit

なので、​JS の​世界では​型安全に​ Endpoint を​呼び出し、​その他の​言語からも​通常の​ API と​して​叩けるように​するには、​現状 Hono が​最適解だと​考えている。

Hono RPC + SvelteKit + Cloudflare の​落とし穴

Hono RPC に​ついての​解説は​以下を​ご覧いただきたい。 https://zenn.dev/kosei28/articles/f4bac1ed2b64a7

https://zenn.dev/yusukebe/articles/53713b41b906de#rpcモード

kosei28 さんの​記事通りに​実装すれば​ SvelteKit で​ Hono が​動くようになる。 Node.js などで​動かす​場合は​これで​完璧であろう。

が、​Cloudflare Pages/Workers で​動かすときには​注意が​必要なので、​差分を​メモが​わりに​以下に​示す。

Route を​作る

kosei28 さんの​記事ではhooks.server.tsで​ルートの​分岐を​書いている。

import type { Handle } from '@sveltejs/kit';
import { app } from '$lib/hono';

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

	return resolve(event);
};

しかし​実際に​ wrangler で​これを​動かすと、​ルートが​存在しない​旨の​エラーが​出る。 なので、​実際に​ルートを​定義してやる​必要が​ある。

import { app } from '$lib/api/server.ts';

export async function GET(event) {
	return await app.handleEvent(event);
}

export const POST = GET;
export const PUT = GET;

ちなみに​この​エラーは​ Hono に​限らず、​yoga でも​ tRPC でも​出たので、​hook で​処理するのは​悪手かもしれない。

env や​ context を​ SvelteKit 側から​ Hono へ渡す

Hono ではexecutionCtxenvを​通して​ Cloudflare の​ KV や​ R2、​D1、​環境変数に​アクセスできる こんな​感じ

import { Hono } from 'hono';

const app = new Hono();

app.post('/', async (c) => {
	const key = 'example_key' as const satisfies string;
	const result = c.env.BUCKET.get(key);
	c.executionCtx.waitUntil(
		c.env.KV.put(key, data)
	);
	return c.jsonT({ result }, 200);
});

だけど、​SvelteKit のload関数からapp.handleEventを​使って​ hono に​ event を​渡してしまうと、​hono からはenvcontextに​うまく​アクセスが​できない。 なので、​上記の+server.ts関数を​修正して、​うまくenvcontextを​渡してやる​必要が​ある。

ところで、​SvelteKit の​ load 関数ではenvcontextに​アクセスする​ときにはplatform変数を​経由する​ことになっている。

https://developers.cloudflare.com/pages/framework-guides/deploy-a-svelte-site/#sveltekit-cloudflare-configuration

つまりplatform変数から​必要な​情報を​取り出して​ hono 側に​どうにかして​渡して​やれば​良いのである。

どう​するって? app.handleEventの​代わりにapp.fetchを​使う。 app.fetchだと、requestを​渡すときに、env や​ context も​明示的に​渡せるのだ^念の​為触れておくと、​`app.handleEvent`を​使っても​`platform`は​取り出すことができる。​なぜなら​`app.handleEvent`は​引数の​`event`を​`c.executionCtx`に​格納する​ため。​つまり​`c.executionCtx?.platform?.env?.BUCKET`のように​して​アクセスする​ことは​一応できる。​ただ、​Hono っぽくない

https://hono.dev/api/hono#fetch

まあ​ここら辺の​違いは​ソースを​実際に​読んで​みるのが​早いと​思う。 https://github.com/honojs/hono/blob/94812fcf2db49bc26dd5e421610433e7510c0529/src/hono-base.ts#L347-L353

と​いうわけで​書き換えた+server.tsが​こちら

import type { HonoBindings } from '$lib/api/server.ts';
import { app } from '$lib/api/server.ts';

export async function GET({ request, platform }) {
	const Env = {
		...platform?.env,
		...(platform?.caches ? { caches: platform.caches } : {}),
	} as const satisfies HonoBindings;

	return await app.fetch(request, Env, platform?.context);
}

export const POST = GET;
export const PUT = GET;

ちゃんと​補完が​効くようにHonoBindingsを​定義しておく​(定義は​以下の​コード参照)。 このHonoBindingsを​ Hono の​ app 初期化時に​渡して​やれば、​Hono で​ルートを​定義する​ときにも​型補完がでて​うまくいく。 ついでに、​API を​叩く​ためのhcを​ hooks.server.tsで​定義しておくと、locals経由で​他の​ルートに​あるload関数から​アクセスできるようになる。

import { Hono } from 'hono';

export type HonoBindings = Partial<
  App.Platform['env'] & { caches: App.Platform['caches'] }
>;

export const app = new Hono<{ Bindings: HonoBindings }>().basePath('/api');

const route = app.get('/hello', async (c) => {
	const result = c?.env?.BUCKET?.get('hello');
	return c.jsonT({ result });
});

export type AppType = typeof route;
import type { AppType } from './server';
import { hc } from 'hono/client';

export function getClient({ fetch = globalThis.fetch } = {}) {
	return hc<AppType>('/api', { fetch });
}
import { getClient } from '$lib/api/client';
import { User } from '$lib/type';

export async function handle({ event, resolve }) {
	/** hookのなかで認証系のAPIを叩いたりすると無限ループに陥るので、即resolveする */
	if (event.url.pathname.startsWith('/api')) {
		return resolve(event);
	}

	event.locals.honoClient = getClient({ fetch: event.fetch });

	/** ... */

	return resolve(event);
}

まとめ

Happy Coding! 質問等​あれば​コメントに​どうぞ。

comment on bluesky / twitter
CC BY-NC-SA 4.0 2022-PRESENT © ryoppippi