shimotsu tech

Webフロントエンドエンジニア @ to-R inc.

『新しいLinuxの教科書』を読んだ

『新しいLinuxの教科書』を読みました。以下、感想です。

  • そもそものモチベーションとしては、「Web系のITエンジニアなのに、Linux のことを知らなすぎるから」でした。「OSの一種である」「Macのターミナルでの操作は、Linuxでのそれに近い」くらいのざっくりとした知識しかなく、さすがにITエンジニアとしてバックボーンに持つべき知識としてはペラすぎると危機感を覚え、一度基礎レベルを体系的に学ぼうと思ったことがきっかけでした。

  • 一通り読んで、基本をとても大事にしている本だと感じました。知ってる人にとってみればイージーな内容ですが、「シェル」のことすらロクに知らないレベルの僕にとってはありがたいレベル設定でした。

  • それまで、普段浸かっているMacのアプリケーションにおける環境設定でたまに出てくる「パスを通す」という概念について、ふわっとした理解しかしていませんでしたが、上記の通り懇切丁寧な基礎のレクチャーによって、完全に理解できるようになりました。Macを使かっているのだから関係ないと切り捨てるのではなく、もっと早く Linux のことを学ぶべきだった...!と心底後悔しました。

  • この本のおすすめとして、VirtualBox による仮想の Linux 環境の構築方法が紹介されていたので、それ通りに進め、各種コマンドをちまちま叩いて確認したりしました。このとき、はじめて仮想環境ながら LinuxCentOS)環境を構築したのですが、「これが Linux ってやつか......」と謎に感慨が深かったです。VirtualBox がインストールされていない場合はちょっと面倒ですが、それでも理解度がグンと高まるので、この本を読む際には実際に手を動かしてみることをおすすめします。

  • 権限周りの章を読んでいるときに、アイデアがふっと降ってきたので、それ用のシミュレーターを作ってみました。これに関しては、React のいい練習になったという感じです。

  • おしゃれな CSS のトリックとか、モダンなフレームワークの使い方は場合によってはすぐ廃れちゃうけど、こういった技術を支える根幹となる知識は下手すると何十年も使えるものなので、ちゃんと勉強していこうと再確認しました。

『Web配信の技術』を読んだ

『Web配信の技術』を読みました。

フロントエンドエンジニアとして働いているので、プロダクションレベルでがっつり配信周りの環境を構築したことはないのですが、昨今かかわるWebアプリケーション開発において CDN を利用しないことはほぼないので、そのあたりの理解を深めようと思ったのが、本書を手にとったきっかけです。

本書はメインテーマをWeb配信の関連技術とし、基礎的な知識の共有からはじまり、適切な Proxy/CDN の設定にいたるまで網羅的に細かく書いてあります。特に、紙面の大部分を「キャッシュ」に割いており、「キャッシュについてそこまでほとんど意識したことがない」という初心者にとっても分かりやすい構成になっていると思います。

自分自身まさにそのタイプの初心者で、Web制作が主戦場のフロントエンドエンジニア → Webアプリケーション開発が主戦場のフロントエンドとしてキャリアを歩んできているので、正直これまでそこまでキャッシュについて気を配ってきた意識はありませんでした。あるとしても、制作物のレビュー時にブラウザに残っているキャッシュをクリアするときだったり、とかその程度。関わっているプロジェクトで CloudFront を扱っていたりはするけど、構築するのはインフラエンジニアであって、自分はたまに確認する程度、みたいな感じです。

そういう自分にとって、cache-control: no-cache についての誤解や、プライベートなキャッシュとパブリックなキャッシュの違いなど、素人がまんまとハマりそうな落とし穴について丁寧に解説してくれているのが助かりました。そもそも、cache-control フィールドは一発で理解するのがなかなか難しいと思うし、Proxy/CDN が存在する場合はそれらがクライアント or サーバの両方の立場を取りうるので、その点も結構ややこしいということを知りました。

ぶっちゃけ CDN の細かい利活用の話(6章)〜自作CDNの話(7章)あたりはざーっと目を通した程度ですが、それでも1〜5章まででだいぶ読む価値はあると思います。

というわけで、キャッシュのことをそこまで深く理解していないフロントエンドエンジニアにとっては必読といえる良書なんじゃないかな〜と思いました。

Next.js 製の静的サイトを S3 + CloudFront + Github Actions で自動デプロイする

これまで Next.js や Gatsby を使った Jamstack なサイトを作る際には Netlify や Vercel といったホスティングサービスを使うことがほとんどでした。

そこで Jamstack に関する理解を深めるため、AWS の S3 + CloudFront (デプロイは Github Actions で自動化する) という、もう少しマニュアル寄りな構成で Jamstack なサイトを構築してみました。

最終的にできたサイトは以下になります。
https://d1ocf77jr70lm2.cloudfront.net

今回は、構築のための方法を自分用にざっくりメモがてら書いていきます。詳細な手順を記すものではないことをご了承ください。

Next.js でサイトを構築

  • まず、通常通り Next.js でサイトを構築する。内容はいたってシンプル。リポジトリこちら

  • たいていの場合、コンテンツはヘッドレスCMSなどから取得するが、今回は簡単にするためリポジトリ内に設置した json ファイルからコンテンツを生成した。内容は、社員名簿的なもの。

{
  "list": [
    {
      "id": 1,
      "name": "hoge1",
      "age": 29,
      "branch": "fuga1"
    },
    {
      "id": 2,
      "name": "hoge2",
      "age": 30,
      "branch": "fuga2"
    },
    {
      "id": 3,
      "name": "hoge3",
      "age": 31,
      "branch": "fuga3"
    }
  ]
}
  • npm script で、静的サイト生成用のコマンド "export": "next build && next export -o dist" を定義する。また通常、next export を実施すると /out ディレクトリに静的ファイルは吐き出されるが、なんとなくの好みで /dist に変更した。

Github Actions 用の設定ファイルを作成

  • リポジトリ内に Github Actions 用の yml ファイルを作成する。mainブランチにpushした際にワークフロー(S3 への同期、CloudFront のキャッシュ削除)が走るようにする。
※デプロイに直接関係ない job については省略しています。

name: Deploy to AWS S3

on:
  workflow_dispatch:
  push: # push時に作動
    branches: [main] # main ブランチが対象

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout # ソースコードのチェックアウト。リポジトリ内のファイルにアクセスする
        uses: actions/checkout@v2

      - name: Setup Node # Node 環境のセットアップ
        uses: actions/setup-node@v3
        with:
          node-version: 16

      - name: Install Dependencies # 依存モジュールをインストール
        run: yarn install

      - name: Export # ビルド & 静的ファイルエクスポート
        run: yarn export

      - name: Deploy # デプロイ実行
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        run: |
          echo "AWS s3 sync"
          aws s3 sync --region ap-northeast-1 ./dist s3://${{ secrets.AWS_BUCKET_NAME}} --delete
          echo "AWS CF reset"
          aws cloudfront create-invalidation --region ap-northeast-1 --distribution-id ${{ secrets.AWS_CF_ID }} --paths "/*"

S3 の設定

  • S3 で静的ファイルを格納しておくバケットを作成する。基本はこちらをベースにおこなっている。

  • バケットポリシーを以下のように設定する(CloudFront のディストリビューションドメイン名は、CloudFrontの設定時に分かるので、最初は仮で後ほど値を入力する)。各ステートメントの意味としては、"PublicList" は Github Actions で S3 にファイルをアップロードするための指定で、"PublicReadGetObject" は CloudFront を経由せず直接 S3 にアクセスするのを拒否するための指定。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicList",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::[AWSアカウントID]:user/[userネーム]"
            },
            "Action": "s3:ListBucket",
            "Resource": [
                "arn:aws:s3:::[バケットネーム]",
                "arn:aws:s3:::[バケットネーム]/*"
            ]
        },
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::[バケットネーム]/*",
            "Condition": {
                "StringLike": {
                    "aws:Referer": "[CloudFront のディストリビューションドメイン名]/*"
                }
            }
        }
    ]
}

CloudFront の設定

  • S3 に引き続き、こちらを参考にディストリビューションを作成する。

  • S3 への直接アクセス拒否のためにカスタムヘッダー設定を行う。CloudFront > ディストリビューション > [ディストリビューションID] > オリジンを編集 からカスタムヘッダーを追加する。これを忘れると、CloudFront のディストリビューションドメインにアクセスしても、ずっと 403 (Access Denied)エラーになる。

  • 404 の場合はNext.js で用意されているエラーページを表示したいので、CloudFront > ディストリビューション > [ディストリビューションID] > エラーページのレスポンスを編集 で、設定する。レスポンスページのパスには、 /404/index.html を指定する。

  • この時点では、サブディレクトリにあるHTMLファイルにアクセスできないので、 CloudFront Functions を利用して参照できるようにする。スクリプトこちらを参考にした。

デプロイ実行

  • main ブランチに push すると Github Actions のワークフローが走る。

    • デプロイに成功すると、S3 にエクスポートされたファイルがアップロードされ、CloudFront のキャッシュが削除される。また、Github Actions 内に Artifact が生成される。
  • S3 および CloudFront の設定が正しく行われていれば、 S3 のバケットウェブサイトエンドポイントにアクセスした際は403が、CloudFront のディストリビューションドメインにアクセスした際は200番台が返ってくるはず。

参考

zenn.dev

qiita.com

blog.cotapon.org

Supabase の supabase.auth.api.setAuthCookie() での Cookie の保存について

Supabase では、認証状態の変更などに応じてトークンを Cookie にセットするためのメソッド supabase.auth.api.setAuthCookie(req: any, res: any): void が用意されています(厳密には、 GoTrue API を利用している)。

例えば、supabase.auth.onAuthStateChange((event, session) => {}) などで event を検知し、その中で上記のメソッドを実行するなどして、最新のセッション情報を Cookie に保管します。

import { NextApiRequest, NextApiResponse } from 'next'

import { supabase } from '@/lib/utils/supabaseClient'

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  supabase.auth.api.setAuthCookie(req, res)
}

実際に実行すると、sb:token というキー名でトークンが保存されています。

f:id:zuboriradio:20220219152930j:plain

で、このsupabase.auth.api.setAuthCookie(req: any, res: any): void ですが、内部の実装は以下のようになっております。

/**
   * Set/delete the auth cookie based on the AuthChangeEvent.
   * Works for Next.js & Express (requires cookie-parser middleware).
   */
  setAuthCookie(req: any, res: any) {
    if (req.method !== 'POST') {
      res.setHeader('Allow', 'POST')
      res.status(405).end('Method Not Allowed')
    }
    const { event, session } = req.body
    if (!event) throw new Error('Auth event missing!')
    if (event === 'SIGNED_IN') {
      if (!session) throw new Error('Auth session missing!')
      setCookie(req, res, {
        name: this.cookieOptions.name!,
        value: session.access_token,
        domain: this.cookieOptions.domain,
        maxAge: this.cookieOptions.lifetime!,
        path: this.cookieOptions.path,
        sameSite: this.cookieOptions.sameSite,
      })
    }
    if (event === 'SIGNED_OUT') deleteCookie(req, res, this.cookieOptions.name!)
    res.status(200).json({})
  }

ここで1点気にしておきたいのが、Cookie Options です。特に maxAge の部分。

export interface CookieOptions {
  // (Optional) The name of the cookie. Defaults to `sb:token`.
  name?: string
  // (Optional) The cookie lifetime (expiration) in seconds. Set to 8 hours by default.
  lifetime?: number
  // (Optional) The cookie domain this should run on. Leave it blank to restrict it to your domain.
  domain?: string
  path?: string
  // (Optional) SameSite configuration for the session cookie. Defaults to 'lax', but can be changed to 'strict' or 'none'. Set it to false if you want to disable the SameSite setting.
  sameSite?: string
}

Cookie の maxAge のデフォルト値は8時間となっており、基本的にはこの値は変更できないようです。 つまり、8時間後に sb:tokenCookie は消えてしまうので、それまでの間にトークンをリフレッシュする必要がありそうです。

なお、どうしても Cookie Options を変更したい場合、Client を別途生成して指定する方法もあるようなので、こちらが参考になりそうです。

github.com

Zod で空文字列を表現する

最近作っているアプリケーションで使っている Zod で、バリデーションに空文字列を指定する必要があったのでその方法をメモしておきます。

github.com

結論

結論から述べると、以下の書き方で実現できました。

const schema = z.object({,
  name: z.string().length(0),
})

ここで躓いたのが、バリデーションにおいて空文字列を許さない場合の書き方は z.string().noempty() と書けるのですが、そのシンプルな逆パターンの書き方がなかったことです。

単純に考えれば z.string().empty() とかでよさそうですが、そういったオプションは用意されておらず、いろいろ探した結果 z.string().length(0) で実現できることがわかりました。

ちょっと発展

少し発展して、バリデーションの条件として【URL(の形式)もしくは空文字列】というケースを考えてみます。

URL の指定は、デフォルトで設けられている z.string().url() を使って表現できます。さらにそのうえで、 2項の Union Types を表現できる .or() メソッドを利用し、以下のように書けます。

const schema = z.object({
  src: z.string().url().or(z.string().length(0)),
})

これで、URLもしくは空文字列のみを許可するスキーマを定義できました。

Supabase のDBからデータを取得する際、ネストされた子レコードの order を指定する

Supabase のDBからデータを取得する際に、ネストされた子レコードの order を指定する方法でちょっと躓いたのでメモがてら書いておきます。

前提

まず、前提として以下の posts テーブルと items テーブルがあると仮定します。ここで、items テーブルの post_id カラムは posts テーブルの id に関連づけられています。

posts:
  id: string
  title: string
  is_published: boolean
  created_at: Timestamp
  updated_at: Timestamp
  description: string

items:
  id: string(uuid)
  created_at: Timestamp
  updated_at: Timestamp
  post_id: string(uuid) // 外部キー
  order_number: number
  body: string

このとき、アプリケーションで posts を取得する際、それに紐づく items を含め、さらに items の順序を order_number によって並び替えたいとします。

方法

以下のように書きます。

const { data: post, error } = await supabase
    .from("posts")
    .select(
      '*, items(*)'
    )
    .eq('id', id)
    .order('order_number', { foreignTable: 'items', ascending: true }) // 昇順 でソート
    .single()

.order('order_number', { foreignTable: 'items', ascending: true }) のように、order メソッドの第二引数に、外部テーブル名とソート方法を指定してやればよさそうです。

参考リンク

supabase.com

github.com

React Hook Form の setFocus() 時に "TypeError: Cannot read properties of undefined (reading '_f')" のエラーが出る

React Hook Form を使ったフォームの実装において、フォームの任意の要素にフォーカスを当てる setFocus() メソッドの実行時に TypeError: Cannot read properties of undefined (reading '_f') のエラーが出てしまうことがありました。

useForm - setFocus | React Hook Form - Simple React forms validation

今回はその原因と解決策をメモがてら書いていきます。

やりたいこと

やりたかいこととしては、とあるタイトルとそれを「編集する」ボタンを置いて、そのボタンを押下したら編集のためのフォームを表示する、というものです。

簡略化すると、以下のようなイメージです。

const PostTitle: React.FC<props> = ({ post }) => {
  const { title } = post
  const [isEditing, setIsEditing] = useState(false)

  const { register, handleSubmit, setFocus } = useForm<Inputs>()

  const handleEdit = () => {
    setIsEditing(true)
    setFocus('title') // ここでエラーが発生
  }

  const onSubmit: SubmitHandler<Inputs> = async (data) => {
    // 送信処理
  }

  return (
    <>
      <div>
        {isEditing ? (
          <form onSubmit={handleSubmit(onSubmit)}>
            <label></label>
            <input
              {...register('title')}
              defaultValue={title}
            />
            <button>保存</button>
            <button
              onClick={() => setIsEditing(false)}
            >
              キャンセル
            </button>
          </form>
        ) : (
          <>
            <h1>{title}</h1>
            <button onClick={handleEdit}>
              編集
            </button>
          </>
        )}
      </div>
    </>
  )
}

そして、フォームを表示する際、デフォルトでフォームにフォーカスが当たるようにしたかったので、setFocus('title') を実行したところ、エラーが発生した、というわけです。

原因

原因としては、 Cannot read properties of undefined (reading '_f') と書いてあるように、プロパティが undefined となっており参照できないことにあります。

おそらくの予想ですが、setFocus() メソッドは、その引数に入力フィールドの名前を(今回の場合だと title)を渡すのですが、handleEdit 関数の実行時、setIsEditing(true) となりフォームがレンダリングされた直後だと、特定のフィールドを参照できないことが原因なのかなと思いました。

解決策

今回は、 setTimeout を使い、setTimeout(func, 0) として解決しました。

0ミリ秒遅延させることで、setFocus() の実行を別のスケジューラのタイミングで実行するようにし、title という入力フィールドを持つ要素に無事アクセスできるようになりました。

const PostTitle: React.FC<props> = ({ post }) => {
  const { title } = post
  const [isEditing, setIsEditing] = useState(false)

  const { register, handleSubmit, setFocus } = useForm<Inputs>()

  const handleEdit = () => {
    setIsEditing(true)

    setTimeout(() => {
      setFocus('title') // これでエラーが発生することなく実行されるようになった
    }, 0)
  }

  const onSubmit: SubmitHandler<Inputs> = async (data) => {
    // 送信処理
  }

  return (
    <>
      <div>
        {isEditing ? (
          <form onSubmit={handleSubmit(onSubmit)}>
            <label></label>
            <input
              {...register('title')}
              defaultValue={title}
            />
            <button>保存</button>
            <button
              onClick={() => setIsEditing(false)}
            >
              キャンセル
            </button>
          </form>
        ) : (
          <>
            <h1>{title}</h1>
            <button onClick={handleEdit}>
              編集
            </button>
          </>
        )}
      </div>
    </>
  )
}

参考リンク

ja.javascript.info