TODO: SkipNavigation

Astroのサーバーエンドポイントにおける redirect() メソッドでリダイレクトできない

Astro でのユーザー認証をやってみたときに redirect() が思った通りに機能せず困ってしまいました。
Astro のレンダリングモードは server、バックエンドは Supabase です。
fetch() で登録用 API を叩く処理を行う際のお話です。フォームの POST で送信する場合はまた違うかもしれません。

結論から

エンドポイントの redirect() を呼ぶだけではリダイレクトしません(ブラウザの表示は切り替わりません)。
redirect() のレスポンスを受けてからフロント側で処理しましょう。

やりたいこと

ページはトップページ(/)と登録ページ(/signup)のみとします。

  • 登録(ログイン)済ユーザーが /signup に来たら / にリダイレクトする
  • 未ログインユーザーが / に来たら /signup にリダイレクトする
  • 登録が完了したらサーバーでログイン処理を行ってから / にリダイレクトする

ミドルウェアを実装

上記のリストの三つめ以外のリダイレクトはミドルウェアで行いましょう。
subabase クライアントの初期化は本当は別ファイルで行うのがいいと思います

typescript
import { defineMiddleware } from 'astro:middleware'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  import.meta.env.SUPABASE_URL,
  import.meta.env.SUPABASE_ANON_KEY
)

export const onRequest = defineMiddleware(async ({ url, cookies, redirect }, next) => {
  // Cookieからアクセストークンとセッショントークンを取得
  // (保存する処理はのちほど)
  const accessToken = cookies.get('sb-access-token');
  const refreshToken = cookies.get('sb-refresh-token');

  if (accessToken && refreshToken) {
    const { data, error } = await supabase.auth.setSession({
      refresh_token: refreshToken.value,
      access_token: accessToken.value,
    });

    if (['/signup'].includes(url.pathname)) {
      return redirect('/')
    }
  }

  if (!accessToken || !refreshToken) {
    if (url.pathname === '/') {
      return redirect('/signup')
    }
  }

  return next()
})

ユーザー登録

つぎはユーザー登録周りの実装です。
(バリデーションやエラーハンドリングは省略します)

フロントエンド

入力された emailpassword/api/auth/signup に投げ、レスポンスをログに出すだけのシンプル構造

html
<form id="form">
  <div>email:</div>
  <div>
    <input type="email" name="email" value="">
  </div>
  <div>password:</div>
  <div>
    <input type="password" name="password" value="">
  </div>
  <button type="submit">送信する</button>
</form>

<script>
  const form = document.getElementById('form');

  if (!form) {
    throw new Error('form not found');
  }
  
  form.addEventListener('submit', async (e) => {
    e.preventDefault()

    const formData = new FormData(form as HTMLFormElement)

    fetch(`/api/auth/signup`, {
      method: 'POST',
      body: formData,
    })
    .then((res) => {
      console.log(res)
    })
  });
</script>

バックエンド

登録完了したら Cookie にトークンを保存してログイン状態にしてからトップページにリダイレクトする
subabase クライアントの初期化は本当は別ファイルで行うのがいいと思います

typescript
import type { APIRoute } from 'astro'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  import.meta.env.SUPABASE_URL,
  import.meta.env.SUPABASE_ANON_KEY
)

export const POST: APIRoute = async ({ request, cookies, redirect }) => {
  const formData = await request.formData()
  const email = formData.get('email')
  const password = formData.get('password')

  const { data, error } = await supabase.auth.signUp({
    email: email as string,
    password: password as string
  })

  if (data?.session)  {
    const { access_token, refresh_token } = data.session;

    // アクセストークンとリフレッシュトークンをCookieに保存
    cookies.set('sb-access-token', access_token, {
      path: '/',
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
    });
    cookies.set('sb-refresh-token', refresh_token, {
      path: '/',
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
    });
  }

  return redirect('/')
}

ここまでできたところで動作を確認してみます。

redirect('/') でリダイレクトしない

スクリーンショット2024-04-2919.27.42.png

Chrome のネットワークタブを確認するとトップへリダイレクト(トップページをfetch)している雰囲気はあるのですが、画面上は変化がありません。

この状態でリロードするとトップページにリダイレクトしましたが、これはミドルウェアのリダイレクトが機能しているものです。

もう一度ユーザー登録を行いログを確認しました。

スクリーンショット2024-04-2919.05.53.png

なんか成功はしていそうだけどエンドポイントの redirect() ってリダイレクトしないの?とパニックになってしまったのでいったん公式ドキュメントを確認

リダイレクト

エンドポイントのコンテキストは、Astro.redirectに似たredirect()ユーティリティをエクスポートします。

「『Astro.redirectに 似た 』ってなに!? どこが同じでどこが異なるの!?」といよいよ大パニックになりましたので Github Copilot に聞くことにしました。

解決方法

バックエンドのコードは特に問題なさそうで、つぎにフロントエンドのコードを提示したところ,,,

JavaScriptのfetch関数はリダイレクトを自動的に追跡しますが、それはリダイレクト先のリソースを取得するためのもので、ブラウザの現在のページをリダイレクトするものではありません。つまり、fetch関数を使用してリダイレクトレスポンスを受け取っても、ブラウザの表示が自動的にリダイレクト先に切り替わるわけではありません。

ブラウザの表示をリダイレクト先に切り替えるには、レスポンスを受け取った後でwindow.locationを設定する必要があります。以下のように修正してみてください:

javascript
fetch(`/api/auth/signup`, {
  method: 'POST',
  body: formData,
})
.then((res) => {
  if (res.redirected) {
    window.location.href = res.url;
  }
})
.catch((error) => {
  console.error(error);
})

なんだー、そっち系かー。

完全に Fetch API に対する理解のなさが露呈してお恥ずかしい限り。

トップページのHTMLを取得していた理由も尋ねてみた

スクリーンショット2024-04-2919.27.42.png

自動的に追跡しないのに redirect() でリダイレクト先を取得する意味はなんでしょうか?

上記に対する Copilot の回答(改行はこちらで適宜追加)

fetch関数のredirectオプションは、リダイレクトレスポンスをどのように処理するかを制御します。
redirect: 'follow'を設定すると、fetchはリダイレクトレスポンスを自動的に追跡し、リダイレクト先のリソースを取得します。
しかし、これはブラウザの現在のページをリダイレクトするものではありません。

redirect()関数を使用してサーバー側でリダイレクトを行う目的は、クライアント(ブラウザ)に対して特定のURLに移動するよう指示することです。
これは通常、ブラウザが直接リクエストを行った場合(例えば、ユーザーがURLを直接ブラウザに入力した場合や、ページ内のリンクをクリックした場合)に有効です。

しかし、JavaScriptのfetch関数を使用してリクエストを行った場合、ブラウザは自動的にリダイレクト先のページに移動しません。
その代わり、fetchはリダイレクトレスポンスを追跡し、リダイレクト先のリソースを取得します。
これはfetchが低レベルのHTTPクライアントであり、ブラウザのページ遷移を制御しないためです。

fetch でなくて form の POST で送った場合はリダイレクトが効くということかな...?

おわり

ミドルウェアとエンドポイントの redirect() の違いがわかってなかったのと、fetch の redirect についての知識不足!

あとでよくチェックする

fetch() グローバル関数 - Web API | MDN
fetch() はグローバルのメソッドで、ネットワークからリソースを取得するプロセスを開始し、レスポンスが利用できるようになったら履行されるプロミスを返します。
developer.mozilla.org
Response - Web API | MDN
Response はフェッチ API のインターフェイスで、リクエストのレスポンスを表します。
developer.mozilla.org