📡

Hono RPCとSvelteKitの併用について

2023/06/16に公開9

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でルートの分岐を書いている。

src/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 でこれを動かすと、ルートが存在しない旨のエラーが出る。
なので、実際にルートを定義してやる必要がある。

src/route/api/[...rest]/+server.ts
import { app } from "$lib/api/server.ts";

export const GET = async (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を渡すときに、envcontext も明示的に渡せるのだ[1]

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

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

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

src/route/api/[...rest]/+server.ts
import { app, type HonoBindings } from "$lib/api/server.ts";

export const GET = async ({ 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 を叩くためのhchooks.server.tsで定義しておくと、locals経由で他のルートにあるload関数からアクセスできるようになる。

src/lib/api/server.ts
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;
src/lib/api/client.ts
import type { AppType } from "./server";
import { hc } from "hono/client";

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

export const handle = async ({ 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!
質問等あればコメントにどうぞ。

脚注
  1. 念の為触れておくと、app.handleEventを使ってもplatformは取り出すことができる。なぜならapp.handleEventは引数のeventc.executionCtxに格納するため。つまりc.executionCtx?.platform?.env?.BUCKETのようにしてアクセスすることは一応できる。ただ、Hono っぽくない ↩︎

Discussion

kosei28kosei28

記事の紹介ありがとうございます!
Workersでは試したことないので分かりませんが、自分はPagesでhooks.server.tsを使ってtRPCとHonoを問題なくルーティングできています。
(自分の記事そのままだと、型が壊れてしまっていたので後で直します・・・)

ryoppippiryoppippi

ありがとうございます。
Pagesで動いてましたか!?
自分が試したときにはエラーになっていたので妙ですね。
原因が別だったらまたここで報告します。

ryoppippiryoppippi

@kosei28 さんの記事でHono RPCを知ることになったので、とても感謝しています。
ありがとうございます!

ryoppippiryoppippi

ありがとうございます。
自分の環境では今の実装がベストなのでこのまま行こうと思います

ryoppippiryoppippi

レポジトリありがとうございます
ちなみにエラーの再現なんですが、

https://github.com/kosei28/hono-rpc-sveltekit
作っていただいたこのレポジトリ上で

npm i -D @sveltejs/adapter-cloudflare 

adapterをこちら→https://kit.svelte.jp/docs/adapter-cloudflare に変更

npm run build
wrangler pages dev  ./.svelte-kit/cloudflare/

としてみてください。
これはwranglerを用いたpreview環境なのですが、何度か試しているとworkerからfetchのエラーが出てしまいますね。

幸い、作っていただいたレポジトリにおいては表示の影響はなさそうです。
ただ、自分の作っているアプリでは、hooks.server.tsでrouteを捌く実装の場合、このエラーによってたまに画面が応答しなくなったり、外部の環境からrestとしてapiを呼び出した場合にうまく応答しなかったことがありました

ryoppippiryoppippi

ちょっと追記事項が必要なので、再現のためのレポジトリを作ります

kosei28kosei28

なるほど…
monorepoでgithub actionsからデプロイするためにadapter-cloudflareを使ってるプロジェクトもあるんですが、wranglerでローカルでテストしてなかったので知りませんでした
参考になる情報をありがとうございます!

ryoppippiryoppippi

はい、そうなんです
KVやR2といったCloudflareの機能をローカルで試すためにはwrangler必要なんですよね
ありがとうございます