『新しいLinuxの教科書』を読んだ
『新しいLinuxの教科書』を読みました。以下、感想です。
そもそものモチベーションとしては、「Web系のITエンジニアなのに、Linux のことを知らなすぎるから」でした。「OSの一種である」「Macのターミナルでの操作は、Linuxでのそれに近い」くらいのざっくりとした知識しかなく、さすがにITエンジニアとしてバックボーンに持つべき知識としてはペラすぎると危機感を覚え、一度基礎レベルを体系的に学ぼうと思ったことがきっかけでした。
一通り読んで、基本をとても大事にしている本だと感じました。知ってる人にとってみればイージーな内容ですが、「シェル」のことすらロクに知らないレベルの僕にとってはありがたいレベル設定でした。
それまで、普段浸かっているMacのアプリケーションにおける環境設定でたまに出てくる「パスを通す」という概念について、ふわっとした理解しかしていませんでしたが、上記の通り懇切丁寧な基礎のレクチャーによって、完全に理解できるようになりました。Macを使かっているのだから関係ないと切り捨てるのではなく、もっと早く Linux のことを学ぶべきだった...!と心底後悔しました。
この本のおすすめとして、VirtualBox による仮想の Linux 環境の構築方法が紹介されていたので、それ通りに進め、各種コマンドをちまちま叩いて確認したりしました。このとき、はじめて仮想環境ながら Linux (CentOS)環境を構築したのですが、「これが 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 でサイトを構築
{ "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 の設定
バケットポリシーを以下のように設定する(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番台が返ってくるはず。
参考
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
というキー名でトークンが保存されています。
で、この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:token
の Cookie は消えてしまうので、それまでの間にトークンをリフレッシュする必要がありそうです。
なお、どうしても Cookie Options を変更したい場合、Client を別途生成して指定する方法もあるようなので、こちらが参考になりそうです。
Zod で空文字列を表現する
最近作っているアプリケーションで使っている Zod で、バリデーションに空文字列を指定する必要があったのでその方法をメモしておきます。
結論
結論から述べると、以下の書き方で実現できました。
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
メソッドの第二引数に、外部テーブル名とソート方法を指定してやればよさそうです。
参考リンク
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> </> ) }