Closed5

Remix+CloudflareでWebサイトを作る 22(ページを跨いでエラーメッセージ渡す・D1でのBooleanはInt・Zodで文字列→Boolean・Hello, Baselime!)

saneatsusaneatsu

【2024-04-04】他のページで出たエラーをリダイレクト先で表示したい

背景

17 > 【2024-03-22】remix-google-authでGoogleAuthログインにする でログイン機能を実装した。

現在はざっくり以下のようなことをしている

app/routes/auth.google.callback.tsxapp/services/auth.server.ts を呼び出す
app/services/auth.server.tsではDBを見てユーザーが存在するかチェック
→存在しなければapp/services/auth.server.tsはエラーを投げる
app/routes/auth.google.callback.tsxでエラーをキャッチして/login ページにリダイレクトする

/loginにリダイレクトする時にエラーメッセージを渡して、/loginでどんなエラーになったのか表示したい。

なんとなく実装してみる

redirectってResponseのエイリアスだった気がするんだけどResponse使ったらいい感じにエラーメッセージ渡せるようになったりしない?

auth.google.callback.tsx
export const loader = async ({ request, context }: LoaderFunctionArgs) => {
  try {
    const userId = await authenticator.authenticate("google", request, {
      context,
    });
    return createUserSession({
      redirectTo: "/admin",
      request,
      remember: true,
      userId,
    });
  } catch (e: unknown) {
-    return redirect("/login",);
+    return new Response(JSON.stringify({ message: "Hello world!" }), {
+      status: 400,
+      headers: {
+        Location: "/login",
+      },
+    });
  }
};

こんな感じの画面になる。

調べる

https://github.com/remix-run/remix/discussions/6074#discussioncomment-5625908

auth.google.callback.tsx
export const loader = async ({ request, context }: LoaderFunctionArgs) => {
  try {
    const userId = await authenticator.authenticate("google", request, {
      context,
    });
    return createUserSession({
      redirectTo: "/admin",
      request,
      remember: true,
      userId,
    });
  } catch (e: unknown) {
    return new Response(JSON.stringify({ message: "Hello world!" }), {
+-    status: 301,
      headers: {
        Location: "/login",
      },
    });
  }
};

これでちゃんと /login にリダイレクトされた。
へぇ。
で、次は/loginにリダイレクトした時にエラーメッセージを渡して、表示させたい。

リダイレクト時にエラーは渡せない

https://github.com/remix-run/remix/discussions/2929#discussioncomment-2600068

  1. リダイレクトはターゲットの場所にボディを渡さない。
  2. ローダは GET リクエストのみを扱い、GET リクエストはボディを持ちません。ボディは POST/PUT/PATCH/DELETE のような GET 以外のリクエストでのみ利用可能です。

リダイレクトから別のローダーにデータを渡す方法は2つあります。

  1. クッキー/セッションにデータを保存し、リダイレクト先のローダーでそこから読み込む。
  2. ロケーションURLに検索パラメータとして追加し、そこからローダーで読み込む。

https://stackoverflow.com/questions/71441953/redirect-route-and-display-message

セッションを使う例がこのStackOverflowにあった。
ここでしか使わなさそうだし簡単にパラメーターで渡す実装にしようかな。

Cannot convert argument to a ByteString

auth.google.callback.tsx
export const loader = async ({ request, context }: LoaderFunctionArgs) => {
  try {
    const userId = await authenticator.authenticate("google", request, {
      context,
    });
    return createUserSession({
      redirectTo: "/admin",
      request,
      remember: true,
      userId,
    });
  } catch (e: unknown) {
+   const message = "ユーザー情報が正しくありません";
+   return redirect(`/login?error=${message}`);
};
Unexpected Server Error

TypeError: Cannot convert argument to a ByteString because the character at index 13 has a value of 12518 which is greater than 255.

ASCIIコードでいう255に収まらない文字列使うな、ということでencodeした文字列を渡す。

結論

auth.google.callback.tsx
export const loader = async ({ request, context }: LoaderFunctionArgs) => {
  try {
    const userId = await authenticator.authenticate("google", request, {
      context,
    });
    return createUserSession({
      redirectTo: "/admin",
      request,
      remember: true,
      userId,
    });
  } catch (e: unknown) {
+   let message = "Unhandling Auth Error";
+   if (e instanceof Error) {
+     message = e.message;
+   }
+   return redirect(`/login?error=${encodeURI(message)}`);
  }
};
saneatsusaneatsu

【2024-04-04】D1ではBoolean型の値はIntegerに変換している

問題

ステージング環境でログインするためにBoolean型のisArchivedというカラムをfalse と入れていたら以下のエラーが発生

`prisma.user.findUnique()` invocation:
Inconsistent column data: Could not convert value "false" of the field `isArchived` to type `Boolean`.

解決方法


Query D1 · Cloudflare D1 docs

D1ではBoolean型はIntegerに変換して扱うのでWebから直接値をいじる場合にはtrueの場合は1falseの場合は0を入力する必要がある。

saneatsusaneatsu

【2024-04-05】fetcher.submitでBoolean型の値を渡すと文字列型として解釈されるのでZodのスキーマで文字列→Boobleanに変換する

背景

何かをアーカイブ化するとする。
そのために id: numberisArchived: boolean を使う。
この時fetcher.submit() を使用すると呼び出し先のactionで isArchived が文字列型として解釈される。

admin.tags.$id.tsx
// アーカイブ化する
const handleToggleArchive = () => {
  if (!tag) return;

  fetcher.submit(
    { id: tag.id, isArchived: !tag.isArchived },
    { action: "/admin/tags/archive", method: "PATCH" }
  );
};
admin.tags.archive.tsx
const schema = z.object({
  id: nonEmptyNumber(),
  isArchived: z.boolean()
});

export async function action({ request, context }: ActionFunctionArgs) {
  const formData = await request.formData();
  const submission = parseWithZod(formData, { schema });

  console.log(submission.payload);
  // 🚨 こんなかんじでBoobleanではなく文字列型が入ってくる
  // { id: '1', isArchived: 'false' }

  if (submission.status !== "success") {
    throw new Error('validation error!');
  }

  // 省略
}

解決

そもそもfetcher.submit()をする時になにかもっといいやり方があるのかもしれないがわからなかったので、Zodのスキーマ側で文字列の"true"/"false"が渡された時にBooblean型に変換した。

全然最適解じゃない匂いがする。

/**
 * 文字列の"true"/"false"をboolean型に変換する
 * fetcher.submitを使用すると文字列の"true"/"false"が渡される
 */
export const booleanString = () => {
  return z
    .string()
    .refine((value) => value === "true" || value === "false", {
      message: "Boolean型の値を渡してください",
    })
    .transform((value) => value === "true");
};

const schema = z.object({
  id: nonEmptyNumber(),
  isArchived: booleanString(),
});

ref: Code Snippets · Raul Melo

その他

  console.log(submission.payload);
  // 🚨 こんなかんじでBoobleanではなく文字列型が入ってくる
  // { id: '1', isArchived: 'false' }

そういえばここ、idもNumber型じゃなくてString型なんだけどエラーでないからずっと見逃してた。
schema.prismaでは Int で定義されているし、生成された型定義ファイルも number になっているのになんでエラーでないんだ?

saneatsusaneatsu

【2024-04-06】GitHub Actionsではデプロイが成功しているがCloudflare側でエラー

エラー内容

Error: Failed to publish your Function. Got error: Error: Script startup exceeded CPU time limit.

原因

https://community.cloudflare.com/t/failed-to-publish-your-function-got-error-error-script-startup-exceeded-cpu-time-limit/453909/3

Scriptがでかいと発生するそうで、調べてるとNext.jsでよく発生しているっぽかった。

解決方法

GitHub Actionsでデプロイ再実行すればうまくいくんだけど発生しないようにしたい。

サイズを1MB以内に収めないと色んなところで不都合そうだな。

saneatsusaneatsu

【2024-04-06】Hello, Baselime!

https://x.com/codehex/status/1776437459855007926


料金プランが1つしかなくてそれがFreeなのすげぇ
https://baselime.io/pricing

https://blog.cloudflare.com/cloudflare-acquires-baselime-expands-observability-capabilities

To all the developers currently using Baselime, you’ll be able to keep using the product and will receive ongoing support. Also, we are now making all the paid Baselime features completely free.

現在 Baselime を使用しているすべての開発者の皆様は、引き続き製品を使用し、継続的なサポートを受けることができます。また、Baselime の有料機能をすべて完全に無料にします。

BaselimeがCloudflareに買収されて色々無料になったとのことでCloudflareと連携してみる。
Cloudflare Developer Weekが沢山TLに流れてくるので全部見ておかなくては...。

https://baselime.io/blog/cloudflare-observability-with-baselime

ログ収集してエラーを追跡したりアラート出したりしてくれる。
New Relicとか使ってみたかったんだけどCloudflareとずぶずぶならこれにしよっかな。

Googleで「baselime 監視」とかでググってもなんもひっかからん。

トップページSandoboxへのリンクがあるのでそこを開いてみるとこんな画面が表示される。

連携手順

https://baselime.io/docs/quick-start/


Connect Cloudflareを選択

こんなサイドバーがでてくるので「Click here」をクリックしてAPI Tokenを作成。


クエリパラメータを渡すを初期値を入れてくれるっぽくて、トークン名が「Baselime」になって色々値が入っている。

このまま「概要に進む」をクリック。


UI可愛い。


API Keyを作成

curl -X 'POST' 'https://events.baselime.io/v1/logs' \
  -H 'x-api-key: $BASELIME_API_KEY' \
  -H 'Content-Type: application/json' \
  -H 'x-service: my-service' \
  -d '[
        {
          "message": "This is an example log event",
          "error": "TypeError: Cannot read property 'something' of undefined",
          "requestId": "6092d6f0-3bfa-4d62-9d0b-5bc7ae6518a1",
          "namespace": "https://api.domain.com/resource/{id}"
        },
        {
          "message": "This is another example log event",
          "requestId": "6092d6f0-3bfa-4d62-9d0b-5bc7ae6518a1",
          "data": {"userId": "01HBRCB38K2K4V5SDR7YC1D0ZB"},
          "duration": 127
        }
      ]'

{"message":"Request Accepted"}%

成功することを確認。

https://baselime.io/docs/sending-data/platforms/cloudflare/pages/

Cloudflare Pagesとの連携の例も載っている。

このスクラップは2024/04/06にクローズされました