Nuxtを使ってシンプルなTodoアプリを作ります。ただし、サーバーのルートを手動で定義してクライアントでフェッチする代わりに、Prim+RPC を使って、Nuxtのサーバーで関数を定義し、クライアントから直接その関数を呼び出します(実際にインポートすることなく)。

問題点

クライアントからサーバーの関数を呼び出す際に、HTTPサーバーを設定する手間がかかります。アイディアで遊んでいるときは、シリアライゼーション、型生成、ファイルアップロードのサポート、HTTPメソッドやステータスコードなどについてはまだ深く考えないものです。幸いなことに、NuxtはHTTP呼び出しを容易にするためのユーティリティを提供しています。NuxtのサーバーAPIルートとuseFetchコンポーザブルを使うことで、HTTPに関するいくつかの手間を省くことができます。ルートの定義文法はクリーンで使いやすく、useFetchコンポーザブルと組み合わせることで、ルートは部分的に型付けされ、クライアントから戻り値の型を知ることができます。例を以下に示します。

// サーバー: @/server/api/hello.ts
export default defineEventHandler(event => {
  const { name = "world" } = getQuery(event);
  return { hello: String(name) };
});

// クライアント: @/pages/index.vueの中
const { data: hi } = useFetch("/hello", { query: { name: "world" } });

いくつか注意点があります:

  • 各HTTPルートはNitroでは独自のファイルが必要です。"catch-all"ルートを使用することが例外ですが、その場合はすべてのURLをチェックする必要があります。
  • TypeScriptでのルートのサポートは、Nuxtのサーバーを使用している限りでは機能します。この記事を書いている時点では、クエリはまだ型付けされていません。
  • 本当にやりたいのはクライアントからサーバーに定義された関数を呼び出すことであり、特定のHTTPサーバーに固有のルートをまだ書いています。

Prim+RPCを使った前回の例は以下のように変わります:

// サーバー: @/functions/index.ts
export function hello(name = "world") {
  return { hello: String(name) };
}
hello.rpc = true;

// クライアント: @/pages/index.vueの中
const { data: hi } = useAsyncData(() => backend.hello("world"));

私たちのサーバー呼び出しは今や通常のJavaScript関数のように見えるようになりました。"backend"と名付けたクライアントを使用して、その関数を呼び出すことができます。戻り値は私たちが返したものと同じですが、Promiseでラップされているため(useAsyncDataを使用しているためです)。パラメータもクライアントで型指定されていることに気付くでしょう!

実際に関数が呼ばれるように見えるかもしれませんが、クライアントはJavaScriptのプロキシを使用して関数名と引数を横取りし、私たちのためにサーバーへのリモート手続き呼び出し(RPC)を構成します。Prim+RPCのサーバーとクライアントがサーバー/クライアント間の通信を扱うため、私たちはただ機能を呼び出すことができます。

それでは実際に動かしてみましょう

簡単なチュートリアルでこれを実演しましょう。すでにほぼ準備が整っている基本的なtodoアプリを作成しました:サーバーロジックは準備ができており、クライアントアプリも既に作成されています。不足しているのは、サーバーとクライアントがまだ通信できないことだけです。

チュートリアルを進めていくには、以下のように未完成のデモをダウンロードしてください:

npx giget@latest "gh:doseofted/todo-nuxt-prim-rpc-demo#starter"

このコマンドでGithub上のデモリポジトリのコピーをダウンロードします。完成したコードのコピーをダウンロードしたい場合は、上記のコマンドから#starterを削除してください。このプロジェクトをお好みのコードエディタで開いてください。

注:このデモはNode 18以降を使用しています。より低いバージョンを使用している場合は、nvmのようなバージョンマネージャを使用してNodeのバージョンを簡単に変更することができます。

最初に、環境ファイルの例をコピーしてプロジェクトをセットアップしましょう。このファイルにはDATABASE_URLの値を生成するために使用できるコマンドが含まれています。これはPrismaがSQLiteファイルと通信するために使用する接続文字列です。

cp .env.example .env
echo "DATABASE_URL=\"file:$(pwd)/data/dev.db?connection_limit=1\"" >> .env

これで次の3つのコマンドですべて始めることができます。1. 依存関係のインストール 2. SQLiteデータベースの準備 3. プロジェクトの起動です。

npm install && npm run migrate:dev && npm run dev -- --open

ブラウザのウィンドウが自動的に開きます。まだタスクが表示されていないのがわかります。これはアプリがまだサーバーと通信できないからです。

@/functions/index.ts@はプロジェクトのルートを表しています)で見つけることができるモジュールにサーバーで呼び出すべきすべての関数があります。これは主にtodo項目を作成する一連の関数です。Nuxtだけであれば、一連のNitroサーバールートを通して関数を公開します。この例では、単一のAPIルートを使用してPrim+RPCを設定します。

パス @/server/api/[...].ts にファイルを作成します(これは"catch-all"ルートです)

import { createPrimServer } from "@doseofted/prim-rpc";
import { defineH3PrimHandler } from "@doseofted/prim-rpc-plugins/h3";

const prim = createPrimServer({
  module: import("../../functions"),
  prefix: "/api/prim",
});

export default defineH3PrimHandler({ prim });

このファイルではPrim+RPCの"サーバー"を作成しました。これは、RPC(通常はJSONオブジェクト)を関数呼び出しに変換し、戻り値をRPCレスポンスに変換するフレームワーク非依存のユーティリティです。"サーバー"という言葉を引用符で囲んでいるのは、それだけでは何も提供していないからです。RPCが旅するチャネルを定義する必要があります。defineH3PrimHandler()は、Nuxtと互換性のあるunjs/h3ルートを作成します。

デフォルトでは、許可されたマークを付けない限り関数を呼び出すことができません。これを行うには、各関数に .rpc プロパティを追加します。

@/functions/index.ts を開いて、hello() 関数にこのプロパティを追加します。

export * as todo from "./todo";

export function hello() {
  console.log("Hello server!");
  return "Hello client!";
}
hello.rpc = true;

export type { TodoItem } from "./todo/schema";

クライアントをまだ設定していなくても、すでに hello 関数を呼び出すことができます!次のURLを試してみてください。

curl --request GET \
  --url http://localhost:3000/api/prim/hello

ご挨拶の結果がリクエストで表示されると共に、サーバーのコンソールにも表示されます。サーバーに与えたモジュールは http://localhost:3000/api/prim で提供されています。通常はPOSTを介してJSON本体を使ってリクエストが行われますが、詳細を気にする必要はありません。なぜなら、次のステップでこれらのリクエストを自動的に行うPrim+RPCクライアントを設定するからです。

クライアントを作成しましょう。@/composables/backend.ts というパスにファイルを作成します。

import { createPrimClient } from "@doseofted/prim-rpc";
import { createMethodPlugin } from "@doseofted/prim-rpc-plugins/browser";

export const backend = createPrimClient<typeof import("@/functions")>({
  module: process.server ? import("@/functions") : null,
  endpoint: "/api/prim",
  methodPlugin: createMethodPlugin(),
  clientBatchTime: 15,
});

export type { TodoItem } from "@/functions";

このcomposable(backend)は、アプリ全体で関数をリモートで呼び出すメソッドを提供します。@/pages/index.vueを開いて、ファイルの先頭にある行を以下のものに置き換えます。

// この行を置き換えます
tryOnMounted(() => console.log("Not implemented"))
// この行に置き換えます
tryOnMounted(() => backend.hello().then(console.log))

これでhttp://localhost:3000をブラウザで開き、右クリックしてページを検証します。コンソールタブを開くと、クライアント用のコンソールに挨拶が表示されます。サーバーを実行しているコンソールを確認すると、サーバー用の挨拶が見つかるでしょう。さらに良いことに、この関数はTypeScriptで型付けされています。

これでPrim+RPCのセットアップが完了し、クライアントで利用可能な関数を作成し始めることができます。次は、todo関連のすべての関数をユーザーインターフェースに結び付けましょう。

それでは繋げてみましょう!

それでは、このtodoアプリを動かしましょう。@/functions/index.ts ファイルで、以前この行を見たかもしれません。

export * as todo from "./todo";

そのファイル (@/functions/todo/index.ts) を開くと、todo項目との対話に関するいくつかの関数が見つかります。以前見ていた hello() 関数と同様に、必要な関数をエクスポートして、各関数に .rpc プロパティを追加する必要があります。以下の関数にこの操作をしましょう。

export async function count() { /* ... */ }
count.rpc = true;

export async function find(todoId: TodoItemId) { /* ... */ }
find.rpc = true;

export async function check(todoId: TodoItemId) { /* ... */ }
check.rpc = true;

export async function list(page?: z.infer<typeof list.params>["0"], pageSize?: z.infer<typeof list.params>["1"]) { /* ... */ }
list.rpc = true;

export async function create(todo: HTMLFormElement | z.infer<typeof create.todo>) { /* ... */ }
create.rpc = true;

export async function update(todo: HTMLFormElement | z.infer<typeof update.todo>) { /* ... */ }
update.rpc = true;

素晴らしい!これでこれらの関数はクライアントによって利用可能になりました。@/pages/index.vueを開いて、useAsyncData 関数内で初期ページデータを取得する部分を見つけて修正しましょう。

以下の行を@/pages/index.vueで探して置き換えてください。

// この行を置き換えます
const { data, refresh: refreshTodos } = useAsyncData(() => Promise.all([
  [{ name: "N/A", description: "N/A", photo: "", id: -1 }],
  0
]), { watch: [page] })

// この行に置き換えます
const { data, refresh: refreshTodos } = useAsyncData(() => Promise.all([
  backend.todo.list(page.value, pageSize),
  backend.todo.count()
]), { watch: [page] })

このファイルを保存して、ブラウザを開きます(必要に応じてリロード)。

進んでいますね!しかし、タスクをタップすると、タップしたタスクは表示されません。代わりに「N/A」と表示されます。それを今すぐ修正しましょう。

🤔 これらのタスクがどこから来たのか疑問に思うかもしれませんが、@/functions/prisma.ts をチェックしてみると、開発中に空のデータベースに偽のデータを入れる関数を起動時に実行していることがわかります。

@/pages/index/[id].vueを開いて以下の行を置き換えます。

// この行を置き換えます
const { data: page, refresh } = useAsyncData(async<br><br>こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。<br>[https://dev.to/doseofted/easy-rpc-with-nuxt-making-a-todo-app-55bn](https://dev.to/doseofted/easy-rpc-with-nuxt-making-a-todo-app-55bn)