開発日誌

開発した時に躓いたこととかの記録

astroを利用したサイトをfirebaseからVercelに移行した

astroとVercelと公式にホスティングパートナーになったようです。

 

astro.build

 

AstroのMiddlewareがVercelのEdge Middlewareを利用して実行できるようになったり、Vercelの画像最適化をAstroで利用できるようになったりするようです。(※ 誤訳しているかもしれません)VercelのEdge Middlewareが利用できるということは今までより高速に動かせる可能性が高まりますし、画像最適化についてもNext.jsでそのメリットについては重々承知です。

 

これはぜひastro & Vercelの組み合わせのサイトを持っておかねばならぬ、ということで、firebase hostingにのせてたポートフォリオサイトをVercelに移行しました。手順は下記の感じ。

 

  1. Github Actionsで動かしてたfirebaseへのhostingのymlを削除
  2. Vercelに新規Projectを追加、ドメインを設定
  3. CloudflareにてDNSの向き先を変更
  4. 1日1回、RSSを反映のための定期更新をしたいので、Github ActionsでVercelへdeployを追加

 

実際のサイトはコチラですが、見た目は変わらないです。

今はImageコンポーネントも使ってなくて最適化は動いていないので、今度適用やります。webpになってるのは、ローカルで変換してるからですね。1回lighthouseスコアを全部100点狙った時の名残りです。

画像をwebpに変換する作業、地味に面倒なので、Next.jsと同じ感じでpng使ってもwebpになるのは楽になって嬉しいですね。そのあたり面倒でずっと後回しにしてた画像の追加・差し替え、やっとできる。

 

あと最近デザインの勉強をちょっと本腰いれてやってるんですが、そしたら自分のサイトがめちゃダサに見えるのでそのへんも魔改造していこうと思います。目指せデザインエンジニア。

メインブランチ名をmaster -> mainに変更した

 

ポートフォリオのリポジトリのmasterブランチをmainブランチにrenameした。
コピペしたGithubActionsの中に『main』という文字列があり、今まで変更作業をしたことがないので、この機に変更するか!と思い立ってみた。

 

基本は↓の記事を参考にしたのだが、同じ手順で動かず。

masterからmainに変更する(githubのリモート&ローカルブランチ)branches - Qiita

 

私はSettingsのBranchesからではなく、トップからブランチ一覧に遷移した。下記の感じ。

  1. リポジトリのトップのブランチ選択の横の『N branch』からブランチ一覧へ
  2. masterの右側のえんぴつアイコンで名前変更
  3. Code画面に移動すると上記記事の『Codeタブに移動した画面』の2と同じ文章がでたので実行

という感じだった。

実行完了後に見てみたら、Settingsのmasterも自動でmainになってた。

 

masterがmainに変更になるって聞いた当時はめんどくさそ~まあ動くからいっか!で放置したが、意外と簡単に変更できるので、何かの作業ついでに変更するようにしてもいいかもしれない。

ちなみにVercelのSourceブランチの認識は変更されてなかったので、Vercelを利用している人はSettings > Git > Production Branchの確認しておくと吉。

devドメインをGoogle DomainsからCloudflareに移管した

ついに!Cloudflareが.devドメインに対応しました!! TLD Policies | Cloudflare

Google Domainsの売却が発表された当初はClouflareは.devドメインは『Available soon』ステータスでした。 soonってどのくらい!?と思いながら首を長くして待…っていたわけでもないですが、 『.dev を Google Domains から Cloudflare Registrar に移管してみた』を見かけたのでこの度ついに対応していきます!

やっていき

上記の記事だと上手くいかなかったので、下記の記事を参考に対応。DNSの移行すらしてなかったので、たぶんそこがだめだったみたい。 GoogleドメインからCloudFlareに移管する。 - Qiita

色々なミス

https://manasas.dev でエラー

ERR_SSL_VERSION_OR_CIPHER_MISMATCHが出るようになってしまった。 Fix VERSION_OR_CIPHER_MISMATCH · Cloudflare SSL/TLS docsを参考に、SSL > エッジ証明書を確認。なんかまだアクティブじゃない感じだったので、静観。アクティブになったら見れるようになりました。

DNS設定、一部しか移行されない

移管ポチ!っとしたあと、DNS設定が一部しか自動移行されてないのに気づきました。 急いでcloudflareにも再設定。

はてなブログ独自ドメインにならない

初期設定のオレンジの雲ではなく、DNS Onlyにしてグレーの雲にしないといけないみたい。 - Cloudflare の DNS で設定したサブドメインをはてなブログの独自ドメインとして使うときは Status を DNS Only にする - Qiita - Cloudflareへ移行 + はてなブログで不具合(解決済) - 人は歴史を作り出し、人は歴史を語り継ぐ

エラーになってる間は元のURLにリダイレクトしてくれるみたい。 この記事を書いてる今現在、ドメイン設定をチェックして有効にしてもまだリダイレクトされてしまう。どうやら定期で実行されるチェックに通らないと治らない模様。『最終チェック』が更新されたタイミングでなおった。3時間~4時間ごとっぽい。

cloudflare pagesがエラーページになる

お膝元だと設定を変更しないといけないのかしら、と思いましたがそういうわけではないようです。 pages → 落ちてるサイト → カスタムドメインを確認したところエラーになってて、再確認ボタンがあったのでぽちっと。緑のアクティブになって元通りになりました。

そのほか

Vercelにデプロイしてるものは設定だけちゃんとすればOKだった。 https://manasas.dev はfirebase hosting。 VPS(めいどるふぃんの自鯖)も設定だけちゃんとすればOKでした。 はてなブログが1番難しいかも。

雑感

cloudflare、多機能そうだけど、インフラわからない民としては何が何やら……って感じなのでちょっと不安。はてなブログの謎エラーは先人がいなかったら解決できなかったと思う。ありがとう先人。

結構がっつりダウンさせてしまったので、ダウンタイム無しで色々インフラやってるインフラ担当はすごい。感謝しかない。証明書とDNSの設定を先に完璧にしておけば、ダウンタイム無しでできて、はてなブログも3時間も待つ必要はなかったのだろうか。くやしい。

自ブログをPages RouterからApp Routerに変更した

やったこと

  • Next.jsを13.1 -> 13.4にアプデ
  • Next.js を Pages Router から App Router に移行するときにやったこと を参考に _app.ts と Layout を layout.tsx とcomponents/layouts/defaultLayout.tsxに分割
    • 本当はlayout.tsxにまとめた方が良いと思うのだが、header/footerが上手く動かなかったのでとりあえずheader/footerはdefaultLayout.tsxに残した
  • srcsrc/api以外をappディレクトリに移動
    • src/pages/articles/[slug].tsxなどをapp/articles/[slug]/page.tsxのように変更
    • metadataは下記を参考に設定
    • getStaticPropsgetServerSidePropsは中身を取り出してPageコンポーネント内で呼び出すように変更
    • getStaticPathsgenerateStaticParamsにしてpathsだけ渡すように変更
    • src/apiはそのまま(OGP生成APIのみ)
  • storybookはsrc配下だけ認識するようになってたので、app配下を認識するように変更

悩みポイント:RSS feedどうするか

おわり

  • 主な書き換え点はgetStaticProps, getServerSideProps, Headタグくらいなので、興味ある人は移行してみてもいいかも、くらいの移行難度。
    • 前にブログからmicroCMSへの移行を試す記事でも1度試したがGETがPageコンポーネントに近くなったのはしっくり感が高くて好き
  • favicon等も『置くだけ』で設定できるのはlessで良い
    • 上書きも容易なのが体験として本当良い
  • layout.tsxが上手く動かなかったのは追加調査したい、layout.tsxに設定したデフォルトOGPも上手く効いてない気がする。
  • おわり。元のサイトの構成がシンプルなのもあってあまり沼らなかった。めでたし。

Yew(Rsut)をGithubActionsで自動デプロイする

検討

Vercel, Cloudflare

  • 両方試したが、リポジトリ接続はfailedした
  • ビルドコマンドをYewのものにしてもだめ
  • そもそもNode.js以外想定してなさそうだなと思ったので、あまり深堀せずに撤退

Github Actions + Cloudflare

  • GithubActionsでビルドして、静的ファイルをCloudflare PagesでPublishすることにした
  • Cloudflareをチョイスするのは、あまり使ったことないので興味で。

やっていき

- run: rustup target add wasm32-unknown-unknown
- run: cargo install trunk
- run: trunk build --release
  • とりあえずpush。
    • cargo install trunkにまあまあ時間かかる。
  • trunk build --releaseが失敗した
    • headerunresolved importだった。目視してもimportしたいファイルはちゃんとそこにある。困った。
    • ローカルでは発生しない
    • help: to create the module header, create file "src/components/header.rs" or "src/components/header/mod.rs"とエラーの一部に書いてあるが、src/components/header.rs はすでに存在している。
  • リポジトリの実装の時に参考にした『Web フロントエンドエンジニアのための Rust 製 Web フロントフレームワーク Yew 入門 』に戻ってみる。
    • cargo install trunkrustup target add wasm32-unknown-unknownの順番が逆だったので入れ替えてみたが、変化なし。
  • todoの方ではエラーが出ていないのでディレクトリ構造をそろえてみたが、変化なし。
  • Github Actionsのrustupcargoのバージョンがローカルより新しかったので、ローカルの方を更新
    • versionあわせてもローカルでtrunk build --releaseは問題なく通った
  • 他のコンポーネントと比べた時、deriveがないため認識されてないのか?と思ってエラーにでているheaderに追加してみたが、変化なし。

  • 結論として『header.rshedear_section.rsにリネームしたら動いた

    • なぜ。ローカルでは発生しないので、Rust固有ではなく、GithubActions内の環境が原因だと思うが、未解明。
  • 出来上がったサイトサイト
    • スタイルほったらかしなので後でなおす
  • 完成したyml

おわり

  • 『こんなんで動くわけが……』と思ってリネームしたら動いてしまったのでとても謎
  • 改めて見るとderiveなど『とりあえずお手本のまま書いてみたが、ちゃんと理解していない』状態なのでちゃんとRustの本を読み切りたい、今年中に。

React Hook Formとreact-dropzoneとZodでファイルアップロードフォームを作って、nodemailerでファイルをメール送信する

  • React Hook Formとreact-dropzoneを組み合わせた時にバリデーションを発火させるのに手間取ったのでメモ。知ってれば難しいことはない。
  • Next.jsのAPIでnodemailerを使ってメールを送るのアップデート版
  • textareaのinputをメール送信するだけの機能に、下記の要素を追加
    • 選択肢を追加
    • ファイルのアップロードを追加(pdfに制限する)
    • バリデーションを追加
  • 機能の実現のために、下記のライブラリを追加した
    • React Hook Form: form項目増えるので管理のため
    • react-dropzone: ファイルアップロードをドラッグ&ドロップで実現したいため
    • Zod: バリデーション機能のため
    • formidable: formのデータ受信のため
  • リポジトリ

UI側

ライブラリ選定理由

  • 元々Formik & Yupの組み合わせを使ってたが、最近はReact Hook Form & Zodの波がキてると聞いてノってみた
  • React Hook Form & Zodの方が軽くてパフォーマンスにも優れてるようなので、使い心地を試したい

やっていき

react-dropzoneのdrop時に、Zodのバリデーションを効かせる

  const onDrop = useCallback(
    (acceptedFiles: File[]): void => {
      setValue('file', acceptedFiles[0], { shouldValidate: true })
    },
    [setValue]
  )

react-dropzoneをpdfのみに制限する

  • inputの時とあまり変わらない
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    accept: {
      'application/pdf': ['.pdf'],
    },
  })

formDataの生成

interface SchemaInterface {
  [key: string]: string | number | Blob
}

interface CustomFormData extends FormData {
  append<T extends string | Blob>(
    name: keyof Schema,
    value: T,
    fileName?: string
  ): void
}

const createFormDatata = (data: Schema) => {
  const formData = new FormData() as CustomFormData
  Object.keys(data).forEach((key: string) => {
    const subKey = key as keyof Schema
    const d = data as SchemaInterface
    const tmp = d[subKey]
    if (typeof tmp === 'number') {
      formData.append(subKey, String(tmp))
    } else {
      formData.append(subKey, tmp)
    }
  })
  return formData
}

POSTのheaderの変更

 headers: {
   'Content-Type': 'application/json',
},

api(Serverless Function)側

  • vercelにデプロイするが、vercel serverless functionはあまり大きなファイルを扱うことはできないみたいなので注意
  • バックエンドのAPIとしてのバリデーションもあった方が良いが、今回はその点はSKIP

やっていき

formidableはv3を利用する

Next.jsのbodyParserはOFFにする

  • Next.jsにbodyParserというbodyをいい感じにparseしてくれる機能がありJSONでリクエスト時は大変ありがたいのだが、formでリクエストするときは不都合になるのでオフにする
  • Routing: API Routes | Next.js
export const config = {
  api: {
    bodyParser: false,
  },
}

おわり🎉

  • ファイルアップロードしてメールが送るができるようになりました🎉
  • formまわりの型、とりあえず赤線は解消できたが、違和感があるのでそのうち再チャレンジしたい
  • ファイルアップロードのAPIって結構大変。これももう少し楽な方法ありそうなので探してみたい。

Newtの複数参照フィールドで絞り込みをする

  • 個人ブログのタグで絞り込みの書き方がイケてないのを思い出した
  • APIによるタグの絞り込みが方法がわからなかったので下記のように全記事を取得してきてjsでフィルタをしていた。記事が少ない&ページングもしてない今は良いが、記事が増えたら破綻するので解消する。
  • リポジトリ


export const getArticleByTag = async (slug: string) => {
  const { items } = await client.getContents<Article>({
    appUid: 'manas-diary',
    modelUid: 'article',
    query: {
      select: ['_id', 'title', 'slug', 'tags', 'body', 'icon', 'summary'],
    },
  })

  const tagItems = items.filter(
    (item) => item.tags.filter((tag) => tag.slug === slug).length
  )

  return tagItems
}

結論

  • 絞り込みは『コンテンツID(_id)』で実施する
  • slugはIDではない
export const getArticleByTag = async (tagId: string) => {
  const { items } = await client.getContents<Article>({
    appUid: 'manas-diary',
    modelUid: 'article',
    query: {
      select: ['_id', 'title', 'slug', 'tags', 'body', 'icon', 'summary'],
      tags: {
        in: [tagId],
      },
    },
  })

  return items
}

結論が出るまで

気づくまで

  • 公式にできそうな雰囲気のことは書いてある

    • Newt CDN API | Newt API Reference
    • Filtersの項のInclusionを使えばよさそうだが、slugを投げても投げてもかえってこない。エラーが出る。
  • ふと気づく。『slugって、idじゃないかも』

  • タグの詳細を確認。名前とslugしかない。ここで撤退してはいけない。左上のページを開くをクリックする。

  • ページを開いた後。右側、ステータスの青いラベルのすこし下に『コンテンツID』が存在している。コードに直貼りで試したところ絞り込みが動いた。つまり、『絞り込みで必要になるIDはコンテンツIDのこと』である。

今のコードを理解しなおす

  • NewtとNext.jsを利用してブログを作成するを参考にペタペタ構築したのだが、この記事のgetArticleBySlugの理解が間違っていた。私はこれを『IDで取得する処理(内部的にarticle/[slug]のようなものになる)』と理解していた。
  • だがQueryに入っているということは、CDN APIのドキュメントのQueriesの欄に該当する
    • つまりここはEquality operatorで合致するslugにフィルタされているだけ
export const getArticleBySlug = async (slug: string) => {
  const article = await client.getFirstContent<Article>({
    appUid: 'manas-diary',
    modelUid: 'article',
    query: {
      slug,
      select: [
        '_id',
        'title',
        'slug',
        'tags',
        'body',
        'icon',
        'summary',
        'publishDate',
      ],
    },
  })
  return article
}
  • その点を理解しなおしたうえで、Queriesの下の注意をしっかり読む。

参照先に対してクエリをかけたい場合、_id に対してのみクエリをかけられる仕様となっております。その他のフィールドに対してクエリをかけることはできません。

  • 先のコードの通り_idというフィールドはコード上にはりつけていたがしっかり認識してなかった
  • というわけで再掲になるが、下記のように修正。
    • getArticleBysTagの引数はslugではなくtagIdに変更
    • queryにtagsでの絞り込みを追加。
      • このブログではtagsは複数参照フィールドなので、一致検索ではなく包含検索で行う。複数参照フィールドは一致検索ができない。
export const getArticleByTag = async (id: string) => {
  const { items } = await client.getContents<Article>({
    appUid: 'manas-diary',
    modelUid: 'article',
    query: {
      select: ['_id', 'title', 'slug', 'tags', 'body', 'icon', 'summary'],
      tags: {
        in: [id],
      },
    },
  })

  return items
}

おわり

  • おわり。コピペするときも自分が何を書いたのかちゃんと理解しようね案件だった
  • 特定の1つの記事をとるのに、Newtのように絞り込む手法とGET /article/idとする手法があると思うが、どちらが良いか甲乙つけがたいなと思う。
    • バックエンドで0件確認して404にしたとて、フロントでも404だから………という処理が入るので、『SQLでとれたデータをそのまま返す』の方がバックエンド・フロントエンド統一した流れで見るとシンプルで良いかもしれない。
  • ちなみにslugって何?についてはMDNでは通常は URL の最後にある Web アドレスの固有の識別部分と説明されている
    • なら、ID同じでも良くない??という気持ちもあるが、確かに『DB上で管理するためのID』と『スコープ内で固有の人間が読める識別子』は分けておいた方がシステム的に良い気がする。納得した。