🐙

Nuxt3でデータの取得先をクライアントサイドから隠すには

2023/10/08に公開

1. はじめに

Nuxt3は、Vue.jsにServer Side Rendering(SSR)機能やその他色々を含めた、非常に便利なフレームワークです。ただ、サーバサイドとクライアントサイドの連携の仕組みを理解して使いこなすのは中々に難しいです。

課題の一つとして、特に何も工夫しないと、画面を構築するためのデータのフェッチ先のURLやAPIシークレットキーなどがクライアントサイドにも共有されてしまうことが挙げられます。この記事では、データの取得先に関する情報をクライアントサイドから隠す方法を検証します。

まず前提として、サーバサイドからしか読めない変数の値を定義するだけなら簡単で、runtimeConfigのprivate機能を利用するだけです。

https://nuxt.com/docs/api/composables/use-runtime-config

しかし、URLなどが隠されてしまうと、クライアントサイドが外部のサーバからデータを直接フェッチすることは当然できなくなります。クライアントサイドにデータの取得先の情報を隠しながら、サーバサイドで取得したデータをクライアントサイドにうまく渡すにはどうしたら良いでしょうか。

この記事では、筆者なりに検証したいくつかの方法を紹介します。より良い方法や他のわかりやすい解説記事などをご存知の方はぜひ教えてください。

また、以下の記事で検証した機能も一部利用します。よければ併せてご覧ください。

https://zenn.dev/knowhere_imai/articles/b8e07c2985f298

対象読者

  • Nuxt3の基礎はご存知の方
  • Nuxt3のSSRやData Fetchingの挙動の理解を深めたい方

TL;DR

  • サーバサイドレンダリング時のフェッチでは、runtimeConfigに定義したURLを普通にuseAsyncDataに渡せば良い。サーバサイドのみが外部からデータをフェッチし、フェッチされたデータはサーバサイドからクライアントサイドに渡される。クライアントサイドでは通信が発生しないため、クライアントサイドから読めない変数に定義されたURLは無視される。
  • しかし、<NuxtLink>でのページ遷移時や、ブラウザでのボタン押下時のスクリプトなどでは、クライアントサイドが外部と直接通信するため上の方法では不十分。server/api以下にプロキシAPIを追加し、その中でruntimeConfigのURLを参照すれば、データの取得先を隠蔽したまま、サーバサイド・クライアントサイドどちらからもデータがフェッチできる。
  • サーバサイドからのフェッチのために、フェッチ先のAPIに必要な認証情報をクライアントからサーバに共有するには、認証情報をCookieに保存してuseCookieを使用する方法がある。サーバサイドにおいてuseCookieを使用すると、クライアントサイドからページがリクエストされた際のリクエストヘッダのCookieにアクセスできる。

2. データフェッチ用のAPIサーバの準備

まず、データフェッチ先の準備として、何かをリクエストしたら何かをレスポンスするダミーサーバを用意します。リクエストやレスポンスの中身は何でも構いません。

この記事の各種コードの実行結果の確認には、/(path)をGETしたらHello, (path)!を返すAPIサーバを作成して使用しました。細かい実装については前の記事をご覧ください。

3. Nuxt3プロジェクトの準備

次に、検証コードを動作させるためのNuxt3プロジェクトを準備します。

まずは新しいNuxt3プロジェクトを生成します。

$ npx nuxi@latest init client

次に、プロジェクト内にpagesディレクトリを作成します。

$ cd client
$ mkdir pages

pagesディレクトリ以下にfoo.vuebar.vueなどを保存してプロジェクトを起動すると、(baseURL)/foo(baseURL)/bar等のURLでページにアクセスできます。

最後に、nuxt.config.tsを以下のように書き換えます。appServerURLの値は、データフェッチ用のAPIサーバのURLに合わせて書き換えてください。

nuxt.config.ts
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  devtools: { enabled: true },
  ssr: true,
  runtimeConfig: {
    apiServerURL: "http://localhost:8000",
  },
})

runtimeConfigの中にキーバリューを定義しておくと、以下のように参照することができます。このキーの値は、サーバサイドではnuxt.config.tsで設定した値になり、クライアントサイドではundefinedになります。

.vueファイル内の<script setup>内でruntimeConfigにアクセスする方法
const runtimeConfig = useRuntimeConfig();
// サーバサイドでは"http://localhost:8000"、クライアントサイドではundefinedになる。
console.log(runtimeConfig.apiServerURL);

ちなみに、nuxt.config.tsにおいて、runtimeConfig.public以下に定義したキーバリューはクライアントサイドからも参照できます。

4. 検証

それでは、クライアントサイドに宛先を隠してデータをフェッチするためのコードを色々と検証してみましょう。

useAsyncDataを普通に使用する

まずは、useAsyncDataを使ってみます。pages/app1.vueというテキストファイルを以下の内容で作成してください。

pages/app1.vue
<template>
  <div>
    <p>{{ response.message }}</p>
  </div>
</template>

<script setup>

const runtimeConfig = useRuntimeConfig();
console.log(`API Server : ${runtimeConfig.apiServerURL}`);

const response = { message: "" };

const fetchMessage = async () => {
  console.log("in fetchMessage");
  const { data } = await useAsyncData(
    'fetchMessage',
    () => {
      console.log("in useAsyncData");
      return $fetch(`${runtimeConfig.apiServerURL}/server`);
    }
  );
  response.message = data.value;
};

await fetchMessage();

console.log(response.message);

</script>

前回の記事でも確認した通り、<script setup>内でuseAsyncDataを使用すると、サーバサイドでデータがフェッチされ、フェッチされたデータはNuxt3サーバからクライアントに共有されます。

yarn devなどでプロジェクトを実行し、/app1をブラウザで開いて実行結果を確認すると、まずサーバサイドのログは以下のようになっているはずです。

/app1のサーバサイドのログ
API Server : http://localhost:8000
in fetchMessage
in useAsyncData
Hello, server!

一方、クライアントサイドでは、ブラウザの画面に「Hello, server!」が表示され、開発者ツールからログを確認すると、以下のようになっているはずです。

/app1のクライアントサイドのログ
API Server : undefined
in fetchMessage
Hello, server!

すなわち、クライアントサイドにはruntimeConfig.apiServerURLの真の値が隠されていますが、useAsyncDataの機能によってデータが共有されていることがわかります。画面もきちんと描画されています。

useAsyncDataをサーバサイドでのみ使用する

今度は、fetchMessageサーバサイドでのみ実行してみましょう。結論から言うと不適切な方法なのですが、SSRの挙動の理解が深まると考え、載せてみました。

pages/app1.vuepages/app2.vueにコピーして、最後の部分を書き換えます。

pages/app2.vue
<script setup>
// 略

// await fetchMessage();
if (process.server) {
  await fetchMessage();
}

// 略
</script>

/app2をブラウザで開いて実行結果を確認すると、まずサーバサイドのログは/app1と変わらないはずです。

/app2のサーバサイドのログ
API Server : http://localhost:8000
in fetchMessage
in useAsyncData
Hello, server!

一方、クライアントサイドについては、まずコンソールのログは以下のようになっているはずです。

/app2のクライアントサイドのログ
API Server : undefined
in fetchMessage
undefined

response.messageundefinedのままです。クライアントサイドにはresponse.messageに値を再代入する処理が一つもないため当然ではあるのですが、ブラウザの画面には「Hello, server!」が表示されているはずです。

これは、サーバサイドおよびクライアントサイドの処理の順序が以下のようになっているためだと思われます。

  1. サーバサイドで<script setup>実行(=データフェッチ)
  2. サーバサイドでレンダリング
  3. レンダリング結果をクライアントに送信
  4. クライアントサイドで<script setup>実行

1番の終了時点でサーバサイドのresponse.messageには"Hello, server!"が代入されているため、2番のレンダリングにもそれが反映されるものの、4番でクライアントサイドで改めて<script setup>を実行する際にはクライアントサイドのresponse.messageundefinedのままなので、このような結果になったのではないかと思います。

ページ遷移時のデータフェッチでも取得先を隠す

SSRモードでは、ページのURLを直接開いた場合はサーバサイドでレンダリングした結果がクライアントサイドに渡されるのですが、<NuxtLink>タグでページ遷移した場合にはクライアントサイドでレンダリングが行われます。よって、前節までの方法は、複数の.vueファイルから構成されるサービスでは不都合です。

試しに、以下のpages/app3.vueを作成してください。/app1へのリンクを持つだけのページです。

pages/app3.vue
<template>
  <div>
    <NuxtLink to="/app1">To app1</NuxtLink>
  </div>
</template>

このページを開いて、リンクをクリックして/app1に移動すると、サーバサイドのコンソールにはログが何も表示されず、<script setup>がサーバサイドで動いていないことがわかります。

また、ブラウザのコンソールにはGET http://localhost:3000/undefined/server 404 (Page not found: /undefined/server)のようなメッセージが表示されているはずです。クライアントサイドから外部にリクエストを送ろうとして、runtimeConfigに定義したURLがundefinedであるために失敗していることがわかります。

この問題を解決するためには、サーバサイドにAPIを実装してプロキシとして利用します。

https://nuxt.com/docs/guide/directory-structure/server

サンプルのコードを作って検証してみましょう。まず、プロジェクトにserver/api/my_fetchディレクトリを作成して、server/api/my_fetch/[name].jsを以下のように作成します。

server/api/my_fetch/[name].js
const runtimeConfig = useRuntimeConfig();

export default defineEventHandler((event) => {
  const name = getRouterParam(event, 'name')
  console.log(`API Server : ${runtimeConfig.apiServerURL}`);
  return $fetch(`${runtimeConfig.apiServerURL}/${name}`);
});

/api/my_fetch/[name]にアクセスすると、本来のデータフェッチ先にリクエストを送るAPIです。

そして、pages/app4_1.vuepages/app4_2.vueを次のように作成します。

pages/app4_1.vue
<template>
  <div>
    <p>{{ response.message }}</p>
  </div>
</template>

<script setup>

const response = { message: undefined };

const fetchMessage = async () => {
  console.log("in fetchMessage");
  const { data } = await useAsyncData(
    'fetchMessage',
    () => {
      console.log("in useAsyncData");
      return $fetch("/api/my_fetch/server");
    }
  );
  response.message = data.value;
};

await fetchMessage();

console.log(response.message);

</script>
pages/app4_2.vue
<template>
  <div>
    <NuxtLink to="/app4_1">To app4_1</NuxtLink>
  </div>
</template>

app4_1.vueは、app1.vueのデータのフェッチ先を/api/my_fetch/serverに置き換え、不要なコードを取り除いただけのものです。また、app4_2.vue/app4_1に遷移するリンクがあるだけのページです。

サーバを立ち上げて/app4_1を直接開くと、サーバサイド・クライアントサイドそれぞれのコンソールに以下のログが表示されます。

直接開いた場合の/app4_1のサーバサイドのログ
API Server : http://localhost/8000
in fetchMessage
in useAsyncData
Hello, server!
直接開いた場合の/app_4_1のクライアントサイドのログ
in fetchMessage
Hello, server!

また、ブラウザの画面には正しく「Hello, server!」と表示されています。

サーバサイドのログに「API Server : http://localhost/8000」が一つだけあり、クライアントサイドにはありません。よって、普段のuseAsyncDataと同じく、新しく作成したAPIへのリクエストは一回しかなく、かつそれがサーバサイドで実行されたことがわかります。

次に、/app4_2を開いてからリンクをクリックして/app4_1に移動すると、今度はログは以下のようになっているはずです。

/app4_2から遷移した場合の/app4_1のサーバサイドのログ
API Server : http://localhost/8000
/app4_2から遷移した場合の/app4_1のクライアントサイドのログ
in fetchMessage
in useAsyncData
Hello, server!

fetchMessage関数内の$fetchはクライアントサイドで実行されているものの、大元のAPIサーバのURLを隠蔽したままmy_fetchを経由してデータを取得できていることがわかります。

server/api以下にプロキシを実装する方法は、ページ遷移時に限らず、ブラウザでのボタン押下などをトリガーとした動的なデータフェッチの際にURLを隠蔽する場合にも有効です。

クライアントサイドの認証情報をサーバサイドに渡す

Nuxt3を本格的なサービスの構築に利用する場合は、単にURLを叩くだけでなく、ログインして取得した認証情報を要求するAPIを使用するケースも多いと思います。

クライアントサイドから直接データをフェッチする分には、単に$fetchの引数で認証情報をリクエストヘッダを渡せば十分です。しかし、サーバサイドからデータをフェッチするには、認証情報をクライアントサイドからサーバサイドに共有する必要があります。しかし、通常ではサーバサイドのスクリプトがクライアントサイドより先に実行されるため、認証情報の受け渡しに何かしらの工夫が必要です。

方法の一つは、useRequestHeadersおよびCookieを利用することです。useRequestHeadersによって、ページの表示をサーバサイドにリクエストした際のヘッダ情報が取得できるため、その中からブラウザのCookieをパースして、認証情報を取り出すことができます。

まず、次のようにserver/api/my_fetch2/[name].jsを作成してください。

server/api/my_fetch2/[name].js
const runtimeConfig = useRuntimeConfig();

export default defineEventHandler((event) => {
  const name = getRouterParam(event, 'name')
  const headers = getHeaders(event);
  const authorization = headers["authorization"];  // lower case

  console.log(`API Server : ${runtimeConfig.apiServerURL}`);
  console.log(`Authorization: ${authorization}`);
  return authorization;
});

リクエストヘッダからauthorizationキーの値を取り出し、それをそのままレスポンスします。この関数ではダミーサーバと通信していませんが、フェッチ先に送信する認証情報がプロキシAPIで取得できているかどうかさえ確かめられれば十分なので、このように実装しました。

また、以下のようにpages/app5_1.vuepages/app5_2.vueを作成してください。

pages/app5_1.vue
<template>
  <button @click="increment">{{ counter }}</button>
  <p><NuxtLink to="/app5_2">app5_2</NuxtLink></p>
</template>

<script setup>
const counter = useCookie("counter");
counter.value = 0;

const increment = () => {
  console.log("increment");
  counter.value++;
}
</script>

/app5_1は、ボタンを押した回数をCookieに保存するページです。また、/app5_2へのリンクも表示します。

pages/app5_2.vue
<template>
  <div>
    <p>{{ response.message }}</p>
  </div>
</template>

<script setup>
const response = { message: undefined };

const parseCookies = (cookieStr) => {
  return cookieStr
    .split("; ")
    .map((v) => v.split("="))
    .reduce((d, kv) => {
      d[kv[0]] = kv[1];
      return d;
    }, {});
};

const fetchMessage = async () => {
  console.log("in fetchMessage");
  const { data } = await useAsyncData(
    'fetchMessage2',
    () => {
      console.log("in useAsyncData");

      var counter = undefined;
      if (process.server) {
        const cookieStr = useRequestHeaders(["cookie"])["cookie"];
        const cookieDict = parseCookies(cookieStr);
        counter = cookieDict["counter"];
      } else {
        counter = useCookie("counter").value;
      }
      console.log(`counter = ${counter}`);

      const headers = { headers: { Authorization: counter } };
      return $fetch("/api/my_fetch2/server", headers);
    }
  );
  response.message = data.value;
};

await fetchMessage();

</script>

/app5_2は、/api/my_fetch2/serverをリクエストし、レスポンスを表示するページです。ただし、送信元がクライアントサイドの場合は、Cookieからcounterの値を取り出して、それをAuthorizationヘッダに設定します。一方、送信元がサーバサイドの場合は、ページのリクエスト時にクライアントサイドのブラウザから自動的に送信されたCookieをuseRequestHeadersによって取り出し、それを解析してcounterの値を取り出してAuthorizationヘッダに設定します。

それでは、プロジェクトを実行して/app5_1を開き、好みの回数だけボタンをクリックしてから、/app5_2へのリンクをクリックするか、/app5_2を直接表示してみてください。ブラウザの画面やサーバサイドのログにボタンをクリックした回数が表示されているはずです。サーバサイドとクライアントサイドのどちらからのリクエストでも、ブラウザのクッキーから認証情報を取り出せていることがわかります。

ちなみに、maxAgeexpiresuseCookieに指定しない場合、NuxtのCookieの寿命はセッションまでであることにご注意ください。

……と、いう方法を先に紹介してみたものの、実は、useCookieはサーバサイドでも使用することができます。

      /*
      var counter = undefined;
      if (process.server) {
        const cookieStr = useRequestHeaders(["cookie"])["cookie"];
        const cookieDict = parseCookies(cookieStr);
        counter = cookieDict["counter"];
      } else {
        counter = useCookie("counter").value;
      }
      */
      
      // これだけで良い。
      const counter = useCookie("counter").value;

useCookieの実装の中身を見てみると、先に紹介した方法のように、サーバサイドではリクエストヘッダを利用してCookieを構築する処理が実装されていました。わざわざ自分で実装しなくとも、NuxtのuseCookieが同じことをやってくれるようです。ただ、仕組みを理解しておいた方がいざという時に役立つ可能性があるかもしれません。

5. 終わりに

Nuxt3のData Fetchingに関して、フェッチ先をクライアントから隠蔽する方法について、実際にコードを作成して挙動を確認しました。

全ての検証結果をまとめると、フェッチ先をクライアントから隠蔽するには、以下のシンプルな方法で十分であるという結論になるかと思います。

  • server/api以下にデータのフェッチ先と通信するプロキシAPIを実装し、useAsyncDataからはこのAPIを叩く。
  • 認証情報はCookieに保存すれば、useCookieでサーバサイド・クライアントサイドともにアクセスできる。

筆者はNuxt歴がまだ浅く、あまり深い知識はありません。誤りや、明快な説明のドキュメントを発見された方は教えていただければ幸いです。

Discussion