Next.jsの自作漫画サイトをVercelからCloudflare Pages+R2に乗せ換えた話

Page content

はじめに

自作漫画サイトRESIGN THREATを、Vercel+S3+Cloudflare(CDN)から、Cloudflare Pages+R2に置き換えた。主にその苦労話。

基本的なスタンス

  1. 基本的にはNext.js、React、Node.jsのバージョンアップのみ。これに伴うライブラリの修正等は行うが、筋トレアプリのときみたいに全面的に内部のコードを書き替えたりはしない。
  2. Pages RouterからApp Routerに書き換える。
  3. Cloudflare Pagesに乗せ換える。画像もS3からR2に移管。

しかしやってみたら想定通りには全然いかず、全面更改を要した、って程ではないが、当初想定していたよりはるかに大量の修正が発生した。それにかこつけて以前から問題視していた箇所の改修(デザイン変更等)を図ったりもしたので、見た目はあんまり変わってないんだが結局システムリプレースにはなってしまったという、割とありがちな話。まぁ、当初の想定が甘すぎたっていう話でもあるんだが…以下、その苦労話。

なお前提として、CloudflareがCloudflare Pages上でNext.jsプロジェクトを開発するためのガイドを作ってくれているので、基本的にそれに倣って進めた。つまり初っ端はnpm create cloudflare@latest my-next-app -- --framework=nextだ。このため、この移行作業は最初は真っ新なNext.jsプロジェクトから始めた。これに、現存するアプリケーションのコードを移植したりしながら育てていったという流れだ。基本的にこのガイドに従えば、Cloudfalre Pages上で動く簡素なNext.jsプロジェクトは問題なく作れる。ただCloudflare Pages特有の事情や、今回の移行作業特有の事情もあって(特に後者)、細部でうまくいかない部分はあった。その辺のTipsを色々書き留めておく。

苦労話

Cloudflare上でビルドできねえッッッ

そもそもの話として、npm create cloudflare@latest my-next-app -- --framework=nextで開始したままだとCloudflare上でビルドが失敗する。どうもyarnのバージョンがローカルと違ってて、これのせいで挙動が違うのが原因のようだ。これはwrangler.tomlに以下2つ

[env.production.vars]
NODE_VERSION = "18"
YARN_VERSION = "1"

を環境変数として定義することで解消。(previewが必要な場合はそっちのセクション#[env.preview.vars]もコメント外して同様に記述する)こちらの記事が参考になった。ちなみに、NODE_VERSION = 18のように、変数値の部分をダブルクォーテーションで囲わないと、認識してくれないので注意。(wrangler.tomlの読み込み自体は成功する。が、無視される。)なお、この記事のように、ダッシュボードの環境変数(Settings>Environment Variables)から設定しても動くのかどうかは、やってないのでわからない。

ちなみに、この設定自体は、ビルドの環境変数でもあるが、同時にアプリケーションの環境変数としても利用される。このテのやつって得てして両者が分離してることがあるので、管理が一元化されてるのはわかりやすい一方、アプリケーションでしか使用しないのにorビルドでしか使用しないのに、両者で同じ環境変数を持たざるを得ないというのは、人によっては嫌がるかもしれないな、と思った。(例えばAWSのアクセスキーとか使ってビルドのときだけS3からモノ持ってきたいみたいな場合、ビルドでだけ使えればいいのにアプリケーションにまで引きずってしまうことになるので、嫌がる人はいそうな気がする)

export const runtime = 'edge';つけねえとCloudflare Pages上ではAPIがビルドできねえッッッ

見出しの通りなんだがそうらしい。export const runtime = 'edge';つけないでAPIあげると以下のようなエラーが出てビルドに失敗する。

10:38:42.601	⚡️ Completed `npx vercel build`.
10:38:44.023	
10:38:44.023	⚡️ ERROR: Failed to produce a Cloudflare Pages build from the project.
10:38:44.023	⚡️ 
10:38:44.023	⚡️ 	The following routes were not configured to run with the Edge Runtime:
10:38:44.023	⚡️ 	  - /api/hogehoge
10:38:44.023	⚡️ 	  - /api/hugahuga
10:38:44.024	⚡️ 
10:38:44.024	⚡️ 	Please make sure that all your non-static routes export the following edge runtime route segment config:
10:38:44.024	⚡️ 	  export const runtime = 'edge';
10:38:44.024	⚡️ 
10:38:44.025	⚡️ 	You can read more about the Edge Runtime on the Next.js documentation:
10:38:44.025	⚡️ 	  https://nextjs.org/docs/app/building-your-application/rendering/edge-and-nodejs-runtimes

getStaticPathsSSGページの書き換えがわからねえッッッ

  1. もともとgetStaticPathsgetStaticPropsで書いてた処理(つまりSSGのページ)を、ほとんどそのまんまgenerateStaticParamsに移植したら、ビルド時に"generateStaticParams cannot be used without opting in to the edge runtime or opting out of Dynamic segment handling"というエラーで落ちた。
  2. とりあえずexport const dynamicParams = false;を付けてみたら、今度はビルド時にTypeError: F is not a functionというエラーで落ちるようになった。生成されたJSファイルを見てみると、このページは特定のClient Component(use clientディレクティブをつけた関数型React Component)をimportして使っているのだが、この関数がそもそも読み込めていないようだった。
  3. 根本的に何かがおかしそうだったのでPages RouterからApp Routerへの移行ガイドを読み直し、これに則って再実装。ガイドに従い、もともとgetStaticPathsおよびgetStaticProps、およびPageのrender()に該当する部分で実行していた処理を、すべて別のComponentで切り出し、そのComponentにuse clientを書いて(つまりClient Componentにして)、page.tsxからimportして呼び出す。これでとりあえず2.のエラーは消えたが、今度は
    Error occurred prerendering page "/_not-found". Read more: https://nextjs.org/docs/messages/prerender-error
    ReferenceError: self is not defined
    
    というエラーが起きるようになった。
  4. ググってみたらこんな記事を見かけた。2.でstatical importしていたComponentを、この記事に従ってnext/dynamicをつかってDynamical Importに変更した。しかしエラーは消えず。
  5. 追加でググってみたところ、似たエラーに悩んでるユーザーが他にもいたことを発見した、が、明確な回答が出ているわけでもなさそうだった。が、ふと気になって手元の/app/not-found.tsxを見るとexport const runtime = "edge";というのがあって、なんとなくこれ外したらいけるかもなと思って外したら、とりあえずローカルではビルド通った。(yarn buildが成功した)
  6. しかしCloudflare Pagesにあげたら今度は以下のようなエラーでビルドが失敗した。
    Error: Importing "@vercel/next": require() of ES Module /opt/buildhome/repo/node_modules/string-width/index.js from /opt/buildhome/repo/node_modules/wide-align/align.js not supported.
    Instead change the require of index.js in /opt/buildhome/repo/node_modules/wide-align/align.js to a dynamic import() which is available in all CommonJS modules.
    
    これはググって見たら "resolutions": { "string-width": "4.2.3" }package.jsonにいれたらいけるよというGihub Issueを見かけたので、とりあえずその通りにしてみた。ら、Cloudflare Pagesでも晴れてビルド完遂。。。

細部は端折ったが、大体こんな感じの流れでSSGページのCloudflare Pagesへの書き換えが完了した。ガイドを読んでなかったのが悪いんだが、「とりあえずPages Routerのコードそのまま移植すりゃ動くだろ~」とか軽く考えてたせいで、特に2.で大分ハマった。これ(Pages Router->App Routerの移行)って、既存のページ描画のロジックをClient Componentで全部外だしして、それを呼び出すように変更しないといけないんだね。いや、まあ、別にいいんだけど、このせいでわざわざReact Componentが増やすことになるので、う~んそんなことしないと移植できないの??(そんなことしてまで移植する価値あるのかな??)って感じ。

また、4.5.のあたりの対処は、書いた通りだが、根本原因を突き止めたうえで実施したものではないので、これで合ってるかどうかわからん。上に挙げたGithub issueでも語られてるが、エラーがなんだかわかりづらすぎる。このエラーだと_not-foundのコードに何か問題がありそうだが、export const runtime = "edge";が解決につながったというのが、エラーと直感的に全く結びつかず、いまだにモヤモヤする。これの対応でdynamicParamspage.tsxや関連のComponentにつけたり値変えたり消したりして色々試したので、コードが大分散らかっている状態になってしまい、自分でもいまだに収拾がついていない。これも含め、新しく追加されたexportの変数(Route Segment Config)の使い方が全般的によくわかっていない。(使いこなせる気もしない)

Pages Routerの時代に「SSG」「SSR」として明確に区別されていた概念が、App Routerでは最早あいまいになっており、思うにここがApp Routerが分かりづらいと思う最大のポイントだと思う。SSGかSSRかについて、App Routerでは実装者が意図してページの挙動を制御できなくなっていて、Next.jsがビルド時にそれをキャッシュできるかどうかで決めるようになっている(ように見える、これを読むと。どういう論拠でそういう判断になってるかしらんけど)。ここがPages Routerの時代までのNext.jsと大きく変わった部分で、個人的にとても取っつきづらい。慣れれば気にならなくなるんだろうけど、この概念の根本的な性質というか、基盤の部分の考えを理解しない限り、「なんとなく」でしか捉えられないまま、「わかりづらいな…」という印象だけが独り歩きしてしまいそうである。個人的に現時点ではこの概念は好きではなく、あまり突き詰めて本質を知りたいとは思えない…人は第一印象が大事っていうでしょう。。それと同じ感じですかね。(?)

その他いろんなエラーが出てきてローカルですらビルドできねえッッッ or 表示されねえッッッ

ビルド時にError: React.Children.only expected to receive a single React element child.

なんだかよくわからんが現行からの移植途中に変なビルドエラーに悩まされた。Error: React.Children.only expected to receive a single React element child.ってエラーである。いろいろ追跡してみると、どうも<div><Link>hogehoge</Link></div>みたいに、Linkタグを別のタグで囲うとこのエラーが起きるようだ。なんだそれ??って感じだが(そもそもそういうモンだったっけ??)、これはこちらのGithub Issueでも議論されていて、Linkタグをaタグに変えたら動いた…とのとことで、実際やってみたらその通りだった。ので、一旦ここは同じようにaタグにしている。このせいで、非常に単純な箇所以外はLinkがもはや絶滅しており、同じ動きをするところは単純なaタグにすべて書き換えることになった。

まぁ回避方法がわかっちゃえば大した話でもなかったのだが、「別のタグに囲われた範囲にLinkつけるとエラー」ってだいぶ条件的に厳しい気がしており、本当にこの条件がNGならほぼすべての箇所でnext/linkが使用できないって話になるような気がする。それだとnext/linkってなんのためにあるんだよ(必要か??)って話にならないか??これはApp Routerにしたせいか、それともNext.jsのバージョンをあげたせいか、どっちなのかわからないが、どっちにしてもなにかモヤモヤするエラーであった。まあそういう意味だとnext/linkの存在意義自体最初の段階から知ってたか?と言われると怪しいんだけどね。。

ビルド時にTypeError: Super expression must either be null or a function, not undefined error

現行には、ページの構成要素にビルド時の動的要素が全くない、ほとんど静的HTMLをべた書きしてるだけの、「ドストレートなSSG」(?)みたいなのが一部存在している。これは現行のメインのReact Componentのreturn句をそのままもってきてビルドしたんだが(それでできると思ってた)、そしたら見出しにあるようなTypeError: Super expression must either be null or a function, not undefinedというエラーが起きてビルドが失敗した。相変わらずエラーメッセージがよくわからないが、ググったらuse clientつければいいよというissueを見つけて、実際これつけたら(つまりClient Componentにしたら)ビルド通った。まぁ別にいいんだけど、冒頭書いたようにこのページはただの静的HTMLなので、これをClient Componentにせにゃならんという扱いがいまいちピンとこない。useStateとか使ってるならわかるんだが、そんなもん使ってないし。

ただ、今振り返って見るに、↑に挙げたPages RouterからApp Routerへの移行ガイドに従うと、「現行のReact ComponentをClient Componentにして(つまりuse clientつけて)外だしして、page.tsxからimportする」が正しい移行方法なので、「現行のReact Componentのロジックをそのままpage.tsxに書き写す」はそこと比べると実際少し間違っている。そういう意味でuse clientつけないとビルドできなかったという事情も、まぁ理解できなくはない。ただこのやり方は、移行ガイドではgetStaticPathsの(つまりSSGページの)移行方法として紹介されているものなので、別にgetStaticPathsなんて使ってないただの単純な静的HTMLなら別にそのまま移行していいじゃんという気はしないでもないし、そういう意味でなんでClient Componentにしないと移行できねえんだよという謎と不満は残る(つーか俺がよくわかってない)。まあ移行できたからそれでよしとする。

というかそういう単純なページならそもそもNext.jsにビルドなんぞさせるな(/public配下にHTMLファイルでもいれて普通に静的ルーティングさせりゃいいじゃん)って話なのかもしれん。。言われてみればその通り。なので最初の発想からしてそもそもズレてるのかも。

追記:
なんとなくだけど、React Anchor Link Smooth Scroll使ってるせいかもしれないな、と思った。そういえばこれ使ってるページ(やComponent)は、ことごとくClient Componentにしている。たまたまpage.tsxに全部renderのロジック書いてた(その中にこいつがいた)から、page.tsx自体をClient Componentにしないといけなくなったというだけかもしれない、と思い至った。ガイドに従ってReact Componentとして外だしして、page.tsxからそれを呼び出す形に変えれば、page.tsxはServer Componentで、呼び出してるReact ComponentだけをClient Component、て感じにできたかも。(まあrenderのロジック書いてる場所が違うだけで実質同じことなんだけど)そういう意味ではこの問題は、 「他のClinet Component化を要するライブラリ(例えばuseStateとか)に比べるとエラーメッセージが滅茶苦茶分かりづらい」 というのが、課題の本質を言い当てているものかもしれない。

Error: NextRouter was not mounted. https://nextjs.org/docs/messages/next-router-not-mounted

とあるページで、ビルドは成功したんだが表示してみたらブラウザ上でこんなエラーが出た。これはURLの記載があるので見てみたら、 appディレクトリ配下でnext/routeruseRouter使うとこのエラーが起きるぜ。next/navigationuseRouterに移行してな!ヨロ!」 って書いてあった。なんだそれ知らねえよそんなの…(どっかで案内されてたらすいません読んでません)このページの話だけでいうと、useRouterrouter.pushでしか利用しておらず、要するにaタグのリンクと同じ扱いだったので、もはやuseRouterなど不要と判断し、aタグに書き換えて回避。…無駄な時間を取られてしまった…

ただ、問題のエラーが起きた個所は、厳密にいうとsrc/components配下のReact Componentであり、src/app配下のpage.tsxで直接useRouterを使っていたわけじゃない。Pages RouterからApp Routerへの移行ガイドに従って、現行のPages Routerのindex.tsxの処理を、ほとんどそのままsrc/components配下に新たなClient Componentとして作成・移管し、App Routerはシンプルにそいつを読み込むような構成に変えて、結果このエラーが出た。つまり、src/app配下で直接的にuseRouterを使っていなくても、そこから読み込んでいる関連Componentが最終的にどこかでuseRouter使ってると結局このエラーが出るということだろう。なのでエラーに記載されたURLの案内内容も若干正確性に欠けると思う。要するに、ソースコードの使用箇所によらず、 App Routerだと昔の(next/routerの)useRouterはそのまま使えない(next/navigationに移行必須) って話なのだ。新規にApp Routerでサイト構築する人なら問題ないのだろうが、今回みたいにPages Routerから移行する場合、人によっては結構困るんじゃないかこれ。と思った。。

{"description":"Route /api/hogehoge couldn't be rendered statically because it used nextUrl.searchParams. See more info here: https://nextjs.org/docs/messages/dynamic-server-error","digest":"DYNAMIC_SERVER_USAGE"}

これはビルド中に発生したエラーではあるが、これでビルドが落ちるというわけではなく、nextUrl.searchParamsの処理がtry-catchで囲ってあれば、恐らくそのまま素通りで通り過ぎ、ビルドは完了する。Next.jsは、どうもビルド時にAPIの処理を実行しているようで、その実行中、APIのnextUrl.searchParamsの部分でこのエラーが起きる。このため、APIの処理をtry-catchで囲っていないと、キャッチできない例外がNext.jsにブン投げられて、ビルドが失敗する、らしい。私の場合はもともとtry-catchで囲ってあったので、try-catchで囲っていないと本当にビルドが失敗する形で落ちるのか、その辺は不明である。が、以下のStack Overflowにそういうことが書いてある。
https://stackoverflow.com/questions/78010331/dynamic-server-usage-page-couldnt-be-rendered-statically-because-it-used-next

ちなみに↑に挙げた「export const runtime = 'edge';つけねえとCloudflare Pages上ではAPIがビルドできねえッッッ」ともちょっと関連するのだが、各APIにexport const runtime = 'edge';を付けておくと、このエラー自体発生しなくなる。なので、App Router時代の正規の対処としては、「try-catchで囲っておく」とかじゃなく、export const runtime = 'edge';を付ける、が正解のような気がする。なんでこれで乗り切れるのか根本的な部分を理解していないが…

これはまあ俺が悪いんだけど、そしてここに書いてある通りなんだけど、昔のNext.js(11とか12の頃)って、Linkタグの中に何故かaタグいれないといけなかったじゃないスか。<Link href={'/hogehoge'}><a>ほげほげ</a></Link>みたいな。それがNext.js 14だともう廃止されているので、つまり<Link href={'/hogehoge'}>ほげほげ</Link>がかけるようになってるので、逆に前のaタグを中に入れてる書き方してるとダメで、これはビルド時のエラーじゃないけど画面表示のエラーになるのです。現行の、最初期に作ったページって、2年以上前で、下手するとNext.js 11の頃だったりするので、そのコードをそのまま移植してくるとこういうレガシーなやつが含まれたりして、エラーになった。まあそういう移植がある場合は気を付けてくださいという話である。ただそもそもの話、昔のNext.jsで無駄にaタグいれないとダメだったのがまずそもそもおかしい気がするし(当時から変だと思ってた)、それを改善したならしたで、昔それで強制していたルールをビルドの段階でちゃんと指摘してくれよという気はしないでもない。そもそもLinkコンポーネントは、↑にも書いたように変な制約や挙動の制限があるようで、イマイチ使うメリットを思いつけないでいる。普通に生のaタグじゃダメなんかね…

スタイルが現行通りに当たらねえッッッ

global.csslayout.tsxにあたってるグローバルなclassはそのまま移植したし、Componentとして分離・統合した部分はあれど各タグのclassNameの値は何も変えずに移植したのだが、なぜか現行通りにスタイルがあたらず、表示が崩れる箇所が複数発生した。例えば現行だと画像が中央寄せになってるんだけど移行後はなぜか左寄せになってたりとか(中央寄せの指示が効いていない)、object-coverによる画像のcropが現行通りに動かず縦横がそのままのサイズで表示されたりとか、hover:opacity-50とかの指定がhoverしなくてもかかったりとか、その他細かい部分で同じようなレイアウト崩れや表示不正が多数。一部に関しては、よくよく探ってみると現行のスタイルがそもそも色々おかしかったりして、「なんでこれで表示が正しくなってるのか不明」っていう箇所がいくつかあったので、そこは正規の(?)修正を施したりはしたが、その他はなぜこうなったのか原因が不明で、一つ一つ内容見ながらちょいちょい修正していった。

これ、単にclassNameの値をちょいちょい書き換えるだけでも修正可能な部分はあったが、それだけだと結構手間取る部分もあり、そういった箇所に関しては、個人的にはもはやそんなところに細かい修正いれて正解探すよりは、もはやタグの配置構造ごと書き換えちゃった方が手っ取り早く、そんなわけでReact ComponentのRender部分のロジックには今回、結構手を入れている。このため、現行と見た目が変わっていなくても、タグの組み方や階層構造は実は結構変わっている、という場合がある。まあ見た目が(ほぼ)同じなら別に中身がどうだとかは見る側にとってはどうでもいい話なのだが、どうせReact ComponentのRenderロジックを修正するなら「良い機会だから今まで微妙だと思ってた部分も一緒に直しちまおう」と思って、表示内容や動作を割と大幅改修してるReact Componentも、いくつかある。例えばMusicページのモーダルはやめて、独自のtoggle開閉構造に変えたりした。これは現行の時から「できればなんとかしたい」と思ってた不満点なのだが、別に大幅にタグを書き換えてまでやるつもりはなく、微妙な改善課題だった。classNameによるスタイリングが現行通りなら恐らく同じままにしていたが、ここに述べた通りで単純移植後にレイアウト崩れが生じたので、現行課題の対応含めて改修しちゃった方がいいや、と思い、今回の改修になった。

App Routerへの書き換えや、後述するreact-swipeable-viewの問題への対応も含めてなんだが、結局この辺でなんだかんだいって大なり小なり改修が発生しており、「現行通りの移植」なんてのは夢物語だったんだな、と今更ながら振り返っている。ただ、App Routerへの移行やreact-swipeable-viewのdeprecationなどは、両方とも明確に「コーディングする」ための理由につながってるのに対し、この課題(スタイリングの不正)は、なぜコーディングせざるを得なくなったか個人的に理由がはっきりしない部分であり、モヤモヤするというか、納得しかねる部分があるのは事実である。移行でレイアウトが崩れたページに、現行でたまたまレイアウト面の不満や改善課題があったために、「どうせ直すなら色々改造しちまうか」という話につながっただけで、移植時点でレイアウト崩れがなければそんな話にすらならなかったはずなので(しかもその想定だったので)、思わぬところで余計な体力と時間を要した、という感想である。まあ移行ってのは大体そんなもんか。。この程度の個人の趣味サイトですらこういうことが起きるんだな…というのを改めて実感したのである。

react-swipeable-viewsが使えねえッッッ

これは「最近のこと」で書いた通りだが、今回Next.jsとReactを最新までバージョンアップしたため("next": "^14.2.4""react": "^18")、react-swipeable-viewsを使ったページがビルドできなくなった。以下のようなエラーが出る。

npm ERR! Could not resolve dependency:
npm ERR! peer react@"^15.3.0 || ^16.0.0 || ^17.0.0" from [email protected]
npm ERR! node_modules/react-swipeable-views
npm ERR!   react-swipeable-views@"^0.14.0" from the root project

これはreact-swipeable-viewsが2022年でdeprecationしているためである。こちらの記事が詳しい。こちらの記事でも移行先のライブラリとしてSwiperが紹介されているが、結果からいうとこのライブラリの採用は見送って、サイト内でスワイプが有効な場面自体を排除した。以下、簡単にその理由など、、、

  • (当然ながら)Swiperreact-swipeable-viewsと使い勝手が少し違う。特に、Tab Indexをクリックして、一気にそのIndexの要素までSkip Swipeする機能の実装が、現行ではuseStateとか駆使してガリガリと実装してるんだが、Swiperで同じ機能性を実装するには少し工夫が必要そうだった。そこまでして「凝る」つもりもなかった。
  • また同じことが起きるとやだな~(Swiperもdeprecationするとか言い出すとまた同じような対応が必要になって、それはやりたくない)という思いがあった。そもそもSwipe自体、個人的な自己満足で実装しており、サイトのユーザビリティとかの観点では必須でもない。凝ったUIを実装しようとしていろんなライブラリに手を出すと、後々これと同じことになって結局似たようなメンテの手間がかかるのでは、と思った。(実際、そうだと思う)
  • 1.「ページ内に複数の暗号が登場する」場合に2.「ページ画像をクリックしてモーダルを開いて」そのあとで3.「対象の暗号にスワイプして移動する」、という一連の条件がそろった場合に初めてスワイプが登場するわけだが、新都社のコメントや今までのログを見る限り、まずそもそも2.に至ってる人が圧倒的に少ない(多分1.から2.に至る時点で1.の対象者の9割以上が脱落している)し、そんな状況下で、そのうえさらに3.を試そうとする人など極極稀だ。この操作性はaboutページなんかでは一応書いてるんだが、よく考えたらいちいちそんなもん読む人は少ないだろうし、「たまたまクリックorタップしちゃった」人が気づくというくらいだろう。漫画画像のページ自体にそのことを明確に注意書きしておかなければわざわざそんな操作をする人はいないだろうな、と(今更ながら)思い至った。そうなると、漫画画像のページ自体にちゃんとそのことを明記する場合、多少デザインの調整はいるが、もはや「モーダル上でスワイプ」なんて柄にもなく凝ったことする必要などない(もっとシンプルでいい)とも思った。

という感じ。2・3点目の理由が個人的には結構大きいと思う。まあそんなわけで、スワイプの実装自体やめた。なお、関連として、モーダルの利用もやめている。モーダルはここ以外にもいろんな場所で(暗号を解読チャレンジする場所にはすべて)使用していたが、今回こういう凝ったUIのライブラリのメンテの手間が非常に面倒だとわかったので、なるべくそういうライブラリを使うのはやめることにした。同じことが起きるたびに同じこと考えるのが面倒だ。そんなわけで、上にも書いたように、チャレンジの挑戦箇所は、いずれもtoggleによる開閉構造に変更することにした。できればこれすら避けたかったが、レイアウトの都合や実装の負担等を考慮に入れて、一旦これでいくことにした。また変えるかもしれない。

画像が最適化されねえッッッ

特にモバイルで表示したときの話なのだが、画像が原寸大で表示されてブラウザの画面外まではみ出してしまう。Developer Toolで見ても、画像を配置しているdivタグはちゃんと表示幅を規定しているのに対し、画像がそれを無視して飛び出してしまっている。色々探ったところ、どうも「画像の最適化」がなされていないようだ、ということに行きついた。このサイトは、画像はCloudflare経由で配信する構成をとっているため、サイト自身のドメインと異なるドメインをsrcに指定している。ローカル画像(リポジトリに含まれる画像)なら、ビルド時に最適化してくれるらしいが、サイト自身のドメインと異なるドメインを画像に指定している場合(これを「リモート画像」と呼ぶらしい)、それをnext.config.jsに指定してあげないと最適化されないらしい。しかもこの書き方が変わっていた。現行(Next.js 12)では、next.config.jsに以下の形で指定すればよかった:

const nextConfig = {
  images: {
    domains: [
      'localhost',
      'hogehoge.com',
    ],
  },
}

Next.js 14ではこの書き方(というかdomainsフィールドの使用)が非推奨になってるらしくremotePatternsを使う方法が推奨されていた。最終的な定義は以下:

const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'http',
        hostname: 'localhost',
        port: '3000',
      },
      {
        protocol: 'https',
        hostname: 'hogehoge.com',
      },
    ],
}

「できればpathnameも含めて全部書け」というようなことがドキュメントには書いてあるがpathnameまで書いたら(多分何かの書き方が悪かったんだろうが)一部の画像が最適化されないままになったので、一旦これで乗り切っている。

この件、結果的にはnext.config.jsに設定追加するだけで完了しており、そこだけ見るとサラッとしてるが、Netx.js 14になると12以前のこの辺の知識や経験がまるで通じず、1から調べ上げる必要があり、なんか色々試行錯誤した。リモート画像の場合はloaderを定義してそれを使わないとダメという説を見かけたので、それを試してみたり(効果はなかった)、Imagefill属性つけた方ほうがいいという説も見かけたので、それも試してみたり(効果はなかった)、、、要するにリモート画像の場合の扱いは、srcにURLを絶対パスで指定しつつ、widthheightが事前にわかってるならそれもImageにそれを明確に定義して(わからないならwidthheight取り除いてfillだけ入れておく)、remotePatternsにそのプロトコルとドメイン・パスを定義しておけばいい、ということらしい。next.config.jsの書き方が少し変わっただけでNext.js 12以前と大体同じだった。

  • ちなみにwidthheightimageに指定しておいてさらにfillをつけると画面表示時にエラーになる(ビルドでエラーにしてくれりゃいいのに…という思いはある)
  • loaderImagesrc属性に指定するパスを相対パスにする場合に使用するもので、srcにプロトコルからパスまで絶対パスで全部指定するなら使用しなく良いもののようだ。ちなみにこれ使うと「画像の読み込み」はブラウザアクセスしているクライアント ではなく サーバーに変わるっぽいので、つまりリモート画像へのアクセス元が変わる。俺の場合、ステージング環境の画像URLへのアクセスは、俺の家のグローバルIPのみ許可するようなWAF設定を施していたため、これにより画像が一切見えなくなった。こういうのも含めてloader使う場合は注意が必要そうである。でも、そういう点を考えると使いたい場面があまり思いつかないけどな。。
  • というかそれ以前に、Next.js 12時代のnext/imagenext/legacy/imageとかいう名前で追いやられてるらしく、Next.js 13以降のnext/iamgeは12以前の同コンポーネントと、名前は同じだが似て非なる別物ということらしいが、そもそもからしてそんなの知らず(意識せず)開発してたし、出発点がまず違った。

その他細かい改善を加えてえッッッ

Next.jsのアップデートとかCloudflare関連とは直接的にはあまり関係のない、その他の小ネタ軍。

「一番上まで戻る」ボタン

ちょっと画面スクロールすると右下の方に出てくる「一番上まで戻る」ボタン。結構縦に長くなる傾向のあるサイトなのでいずれ作りたいな~とは思っていたのだ。なので、作った。このテのやつはググればすぐに出てくるのだが、面倒くさいのでほぼすべてChatGPTに書いてもらった。Typescript、Tailwindcss(かつ標準クラスのみ使用、カスタムクラスは使用しない)、React Component、くらいの条件与えたらすぐに使えるやつを速攻で書いてくれた。

"use client"
import React, { useState, useEffect } from 'react';

const ScrollToTop = () => {
  const [visible, setVisible] = useState(false);

  const toggleVisibility = () => {
    if (window.scrollY > 300) {
      setVisible(true);
    } else {
      setVisible(false);
    }
  };

  const scrollToTop = () => {
    window.scrollTo({
      top: 0,
      behavior: 'smooth'
    });
  };

  useEffect(() => {
    window.addEventListener('scroll', toggleVisibility);
    return () => {
      window.removeEventListener('scroll', toggleVisibility);
    };
  }, []);

  return (
    <div className="fixed bottom-8 right-8">
      {visible && (
        <button
          onClick={scrollToTop}
          className="bg-blue-500 text-white rounded-full p-3 shadow-lg transition-opacity duration-300 ease-in-out opacity-0 hover:bg-blue-700 hover:shadow-2xl"
          style={{ opacity: visible ? 1 : 0 }}
        >
          
        </button>
      )}
    </div>
  );
};

export default ScrollToTop;

あとはこれをlayout.tsxに埋め込んで終了。簡単だった。ほぼ俺は何もやっていないw AI時代のコーディングって感じだなァ。まあこの程度なら今の時代普通かもしれませんね。。

sitemap

小ネタ。昔のサイトではpublic/配下に静的ファイルとしてsitemap.xml用意していたが、なんかもうちょっといい感じにできないのかと思って改善の余地を探った。これは普通にNext.jsに公式の案内があったapp/sitemap.tsつくってdefault functionで必要な要素を持った配列をreturnさせればそれで終了。簡単。なお、公式ドキュメントの案内だと{url:string, lastModified:Date, changeFrequency:string, priority:number}の要素だが、最低限必要なのは{url:string, lastModified:Date}だけである(実際、これでビルド通ってるし、その要素だけのサイトマップが確認できている)。GoogleはchangeFrequencypriorityは無視するという話を見かけたし、実際自分のHugoのブログのサイトマップでもこのフィールドは書き出されてないので、潔く無視することにした。そもそも何設定すればいいかわからんし。。

サイトマップの元データは、トップページに表示している「更新履歴」である。これは内部的にはJSONファイルでデータを管理しているので、このJSONファイルを読み込んでサイトマップの形に成形している。ただもともとサイトマップに利用することを想定して作ってたJSONファイルではないので、今回のために少し改造した。サイトマップ用に更新情報を別途作成・管理することも考えたが、「更新履歴」として管理する情報とほとんど重複するため二重管理は避けたいし、せっかくなら既存資産を流用したほうがいいだろうと考えた。このアイディアは正解だったと思う。ただその分、トップページの「更新履歴」を、もう少し厳密?正確?に管理・運用していく必要が出てきた。あたりまえだけど。。

余談だが、Next.jsのドキュメントで紹介されているsitemap.tsのコードサンプル、lastModifiedが全部new Date()になってるが、これだとビルドのたびに更新されそうで、これはあんまりよくない気がするな。RSSフィードとか不要に更新拾っちゃいそうだけど。実際に中身の更新がない場合にもlastModified更新してっちゃうとクローラーあたりから不正とみなされるんじゃないか??Next.js的には「まぁそこは良しなにしてちょ(わかるでしょ)」みたいな感じなのかもしれんが…

Cloudflare Workersがよくわからねえッッッ

ここからはNext.jsではなくCloudflare Workersの話である。しかし結論から書くと Cloudflare Workersを使うのは俺のやりたいことを満たすのには不適切だという結論に至ったので、最終的にここに書いた苦労話は意味をなさなくなった。 ただし一応いくつか試した結果があるので無駄にしないためにここに記録に残すことにする。

Cloudflare Pagesのdeploymentの掃除に、Cloudflare Workersのscheduled handlerを使ってみることにした。ブログで同じようなことやってるんだが、こっちはLambdaからCloudflareのAPIをコールする実装なので、Cloudflare Workersは使ってない。Lambdaのほうが慣れてるから作りやすいんだが、今回、ホスティングもオブジェクトストレージも(DNS、CDN含め)Cloudflareに移したわけだし、こういう運用タスクも、わざわざ別サービス使うよりCloudflareに全部まとめちゃったほうがわかりやすいよな、と思うに至り、Cloudflare Workersに手を出してみることにした。で、そうなるとここで当然苦労することも出てくるわけである。その苦労話。

スケジュールジョブの組み方

Cloudflare Workersはnpm create cloudflare@latest--templateでテンプレートを与えると、そのテンプレートを使ってプロジェクトを初期作成してくれる。ここでその辺が紹介されている。テンプレートはいくつか種類があるらしいが、一番シンプル(に見えた)Routerというテンプレートを使って作った。これで構築すると、非常にシンプルなWebアプリケーションが構築される。ただ、これは「Webアプリケーション」であり、俺がやりたいことー上に書いたようなスケジュールバッチジョブ的なやつーには適さない。特定のリクエストパスにその処理組んで、IFTTTのスケジューラーとかwebhookでからコールしてもいいんだが、「Cloudflareで完結させる」に反するし、そんなことするくらいならLambdaの方がまだわかりやすいので、その方式はありえない。ってなわけでScheduled Handlerを組み入れた。このサンプルだと、構築直後の段階では

...
export default {
	fetch: router.handle,
};

ってな感じで、export defaultの代表がfetchしかいない状態だが(多分これがWebへのエンドポイント露出を意味するHandlerなのだろう)、別途scheduledの関数を用意して、それもexport defaultに入れてあげることで実現できた:

...
const scheduled = async (event, env, ctx)  => {
	switch (event.cron) {
		case "0 * * * *":
			await hohehoge();
			break;
	}
};

export default {
	fetch: router.handle,
	scheduled: scheduled,
};

環境変数

これが唯一にして最大のネックだった。これが理由でCloudflare Workersを使うのは諦めざるを得なかった。というのも、Cloudflare Workersで環境変数を設定する場合、wragnler.toml[vars]セクションを定義して、そこに環境変数を列挙する形になるわけだが、逆に言うと環境変数をベタ書きした状態のwrangler.tomlをGithubで管理しなければならないということになる。機密情報に当たらないような変数なら歓迎するのだが、俺の場合「Cloudflare Pagesのdeploymentを掃除したい」というのがやりたいことだったので、このためにCloudfalreのAPIトークンが必要になり、これを環境変数として記述しなければならばい。これをGithubに乗っけるのはあり得ない。(というかGithubから警告受けるんじゃなかったっけ、こういうの)資産管理の都合、Githubに乗っけないという選択肢は基本的にはナシなので、残念ながらこの点においてGithub Workersは使えないという結論に至った。

試した限り、ダッシュボード経由でも環境変数は設定できるので、例えば「環境変数はダッシュボードでのみ定義し、wrangler.tomlには何も定義しない」ようにすることで、本番環境でのみ環境変数が定義された状態を用意することはできるが、wragnler.tomlの記述が優先されるため、ダッシュボード経由で登録した環境変数が仮にあっても、deployするたびリセットされる。このため実質wrangler.tomlへの設定記述が必須になる。

これってどうにかならないものなのかね。。こういう用途以外でWorkersを使いたい場面があまり思いつかないのだが。こういうことが気軽にできない(してもいいけど機密情報のおっぴろげが必要になる)とすると、そもそもWorkersに求める考え方が根本的に何か間違ってるような気がしてならない。例えばLambdaの場合、いちいち設定しなくても(Lambdaの実行アカウントの)権限は保有した状態でAWSリソースにアクセスすることはできるようになってるし、ほかのAWS系のサービスでもそのはずだが、Cloudflare Workersの場合はそんなことはなく、基本的に真っ新なコンピュートリソースがEdge Runtimeで動くというだけのように見える。これだと純粋な静的サイトの構築くらいしかできないのではないか…

ちなみにCloudflare Workersで環境変数にアクセスする場合、process.env['hogehoge]だとエラーになる(processなんて存在しねェーよ!って怒られる)。scheduled handlerの場合は、引数のenvに環境変数が入ってるので、env.hugahugaみたいな感じで取り出して使う。以下のような感じ。

...
const scheduled = async (event, env, ctx)  => {
	switch (event.cron) {
		case "0 * * * *":
			await hohehoge(env);
			break;
	}
};

const hogehoge = async (env) => {
  console.log(`env.hugahuga=${env.hugahuga}`);
}

export default {
	fetch: router.handle,
	scheduled: scheduled,
};

まあ、使わないと決定したのでもうどうでもいいんだけどね…

その他

  • Cloudflareをローカルでテストする場合のメモ…大体ここに載ってるけど。。npx wrangler dev --test-scheduledで起動した後、curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*"cronの部分にcron式のパラメータを指定して実行する。これは「そのcron式が示す日時が来た前提でトリガーしろ」という意味で、例えば「毎日5時起動」のcron式を設定していてcurlしたのが4時だとしたら、あと1時間待たないと起動しないのかというとそんなことはなく、curlした直後に「5時になった」ていで動き出す。
  • Github Actionsを使ってCloudflare Workersにdeployする場合のワークフローサンプルはここで紹介されている。で、ここでGithub ActionのSecretにCLOUDFLARE_API_TOKENCLOUDFLARE_ACCOUNT_IDの2つを設定することになるのだが。後者はダッシュボード見ればすぐわかるのでいいとして、前者でちょっと躓いた。このCLOUDFLARE_API_TOKENはCloudflare Workersの編集のパーミッションが必要になるので、それを保有しておく必要がある。これはAPIトークンの作成ページにテンプレートがあったのでそれをそのまま使った。
    20240830-001.png
  • Cloudflare Workersには、このテのサービスにはおなじみの「コール回数の制限」があって、例えば無料プランでは月100,000回で制限されている。この辺に載ってる。まあ100,000回もあれば十分だろうという気もするが、Lambdaの月1,000,000回まで無料(つまりCloudflare Workersの10倍まで無料)と比較すると、やはり少し見劣りする。サービスの性質が異なるので一概には言えないが、ざっと試した感じ、Workersで出来ることはLambdaでも可能なので、環境変数の件も相まって、今の所あえてWorkersを使おうという気には、やはりあまりなれない。メリットを理解していないだけの可能性もあるが…
  • Cloudflare Workersは、他の似たようなサービスと同様、Workersの処理内でconsole.logで吐き出したものをログとして扱う。この辺に載ってる。ただ、ログを参照するためにダッシュボードで「ログストリームの有効化」をしないといけなく、何もしなくてもログを参照できるVercelとかHerokuとかに比べれば若干、手間。むしろ初期状態でなぜ有効化されていないのだ??謎。wrangler tailってコマンドでも見れるらしい。あんまり詳しく読んでないが、ここざっと読んだ感じだと、ログを別の(Third Party製の)サービスに流すことも可能らしく、実質的にはそっちのほうが扱いやすい気がしないでもない。

Lambdaが想定通りに動かねえッッッ

もともとLambdaを使う気がなかった(Cloudflare Workersで代行するつもりだった)のだが、↑にあげた理由の通りでCloudflare Workersは使えないという結論に至ったので、「Cloudflare Pagesのdeploymentの掃除」を行うLambda関数をこしらえることにした。といっても、ほぼ同じ目的用にブログで作ったやつが既にあるので、これをもう少し汎用的にするだけだ。もともとは以下のような感じのコードだった。

const callCloudflareAPI = async () => {
  try {    
      await callCloudflareAPIByProjectName('blog');
  } catch (error) {
    throw error;
  }
};
module.exports = callCloudflareAPI;

今回は「ブログ」のほかに「漫画サイト」のCloudflare Pages Projectも加わるので、対象とするCloudflare Pagesのプロジェクト名をカンマ区切りの文字列としてLambdaの環境変数で定義しておき、初回にsplitして配列で用意しておいて、forEachでグルグル回してcallCloudflareAPIByProjectNameを呼び出す形に変えた。以下のようなコードだ:

const CloudflarePagesProjectNames = ['blog','manga'];
const callCloudflareAPI = async () => {
  try {
    CloudflarePagesProjectNames.forEach(async (projectName)=>{
      await callCloudflareAPIByProjectName(projectName);
    });
  } catch (error) {
    throw error;
  }
};
module.exports = callCloudflareAPI;

が、このコードは想定通りに動かなかった。何度試しても速攻で終了して、CloudflareのAPIがコールされている節がない。色々探ったんだが(fetchがグローバルインポートされてないからなのか…とか。違ったけど)ここで問題だったのはforEach(async (projectName)=>{...})の部分だった。なぜか知らんが、

array.forEach(async (a)=>{
  await hogehoge();
})

というコードのawait hogehoge()の部分、特にawaitの指示が記述通りに動いてくれず、非同期コールした状態でそのまま通り過ぎており、実質APIコールできていない状態だった。これはforEachなんか使わず、

for (let a of array) {
  await hogehoge();
}

と、素直にfor文で書けばそれで想定通り動いた。というわけでそれで修正対応完了。

正直asyncawaitは未だによくわかってないところがあるのは事実だが、最初のコード(forEach(async (projectName)=>{...}))は、ローカルのUbuntu(on Docker)では想定通りに動いた実績があるのに、Lambdaだと動かない(素通りする)のは不思議だ。DockerでもAmazon Linuxとか使ってLambdaと限りなく同じ環境にしたら再現するんだろうか。でもOSとかで挙動変わるのかなこういうの?そういうイメージがないのだが。まあ解決したからいいとして、不思議な課題だった。簡単に終わると思ってたら思わぬところで意外な時間を食わされた問題だった…

VercelとCloudflare Pagesの比較

今回、Vercel上で動かしていたNext.jsのアプリを、Cloudflare Pagesに移行したわけなので、ざっくりと振り返って、両サービスの比較をしてみたい。ちなみにいずれも無料プラン(あるいはそれに近いプラン)の範囲内同士の比較である。

最初に結論から述べるが、全体感でいうと、正直両者とも大差ない。大体同じようなことができるし、できないことは両方ともできない(あるいは金払うor自分でちょっと手間かけることでできるようになっている)。趣味の範疇でちょこっと使うって程度なら、使い勝手は多分ほぼ同じである。ただ、今回移行したアプリは、バックエンドをほぼ必要としない、シンプルなNext.jsのアプリで、しかも大したアクセス数も発生しない、ひっそりとした個人の趣味用サイトに過ぎない。なので、違和感を感じるようなレべルにない、というだけかもしれない。要するにこの程度の趣味サイトならどっち使っても(あるいはHerokuやら他のホスティングサービスにおいても)恐らく大差ないので、好みでいいと思う。言い換えれば、例えばPrisma使ってRDBとつないで~みたいな、Cloudflare Pagesのドキュメントの言葉を借りると「フルスタックな」アプリケーションを構築することを考え出すと、少し様相が変わってくる可能性はある。Cloudflare Pagesはまだ比較的新しいサービスなので、こういった観点ではやはりVercelの方が総合的に見てそういったエコシステムの方面は充実している印象はある。

# 比較材料 Vercel Cloudflare Pages
1 ビルド 1回45分、月100時間まで 1回20分、500回まで
  • Cloudflare Pagsは月の実施時間の制限が特に明記がない。仮に1回20分フルで使って500回ビルドしたら10,000分(約166時間)なので、Cloudflare Pagesのほうが余裕があるが、正直どっちもそんなに必要ない。(どっちも十分な量を備えている。)
  • ビルドの速度でいっても同程度といった体感の印象であるが、昔試したときはVercelの方が速かった。(今思うとこれCloudflare Pagesのほうはビルド成功してたんだか怪しいが)
  • Cloudflare Pagesの場合、ビルドの設定は、↑に書いたようなガイドに従う必要があったり、wrangler.tomlに一手間入れる必要があったりと、Cloudflare Pagesの方が手間がかかるのは確か。この辺の面倒くささは確かにVercelにはなく、そういう意味ではこの分野では総合的に言えばVercelの方が上を行っている印象である。
2 デプロイ形式 GitHub、GitLab、Bitbucket GitHub、GitLab
  • VercelはGitHub、GitLab、Bitbucketの3つが使用可能らしい。Cloudflare PageshaGitHubとGitLabのみ。ただまあ個人的にはGitHubしか使ってないのでGitHubが使えるという点では同等である。どっちもGitHubのリポジトリと繋げて、push(あるいはPRのマージ)と同時にビルドが走る形式。「production」(本番環境)として運用するブランチを決めておき、それ以外のブランチへのpushは「preview」(テスト環境)として扱われる、という点も同様。ただCloudflare Pagesは正規表現使ってpreview環境として構築するdeployのブランチを指定できるという点で、Vercelより細かな制御が可能になっている。個人的にはどっちでもいいかなー、くらい。(あまり拘りはない)
3 デプロイ済環境の扱い
  • デプロイ済の環境は、どっちも同じで、意図的に削除しない限りずっと残るらしい。探したけど制限が見当たらなかった。制限(作成上限)がないことより、作成済の環境が延々残るのが個人的に地味にいやで、用が済んだテスト環境は消えてくれるとありがたいんだが(例えばHerokuのReview Appはブランチ消すと自動で消えるほか、時間で自動で削除する設定もできる)どっちもそういう設定はないようだった。ブランチマージして消したら削除とかくらいはやってくれてもよくない??って感じなんだが、、、
  • 個々のdeploymentはそれぞれ削除できるようになっているが(VercelCloudflare Pages)手動操作が必要で面倒くさい。というわけで定期削除を実施したい。Vercelの場合は「CLIとか使って自分で自動化の処理作るしかないね」って話をしてるGitHub Issueが見つかった。Cloudflare Pagesの場合もほぼ同様で、API使って自動化できる(ここで簡単に紹介してます。)Vercelの「CLI使えば?」は実行環境を限定する気がするので場合によっては厳しい気がするね。まあAPIはありそうだけど。そういう意味ではCloudflare Pagesのほうがやりやすい気はする。ただいずれにせよ、「デプロイ済環境の削除」に関して、自分で何かしら手当を考えないといけない点は、VercelにしてもCloudflare Pagesにしても同様で、ここは個人的に両者とも×評価である。改良を望む。あんまり需要ないのかなここ??
4 リクエストの制限 500,000/(多分)月 (多分)100,000/日
5 Logging 直近1時間 (保存という概念がない)
  • Vercelの場合、無料プランで参照できる範囲は直近1時間だけである。Cloudflare Pages Functionsの場合は「ログストリーム」という機能で実現されるが、これは流れてくるログをリアルタイムで参照して流すだけの機能で、tail -fに近い性質のようで、つまり「保存」という概念がおそらく存在しない。実際このドキュメントにも"Logs are not stored. You can start and stop the stream at any time to view them, but they do not persist."と書いてある。既に流れてきた分も後になってから見ることはできたので、実際にはちょっとの間は残るのだろうが、プラットフォームとして保存の機能は備えていないということだろう。そういう意味ではあえて書くなら「0時間」になるのかもしれない。ただVercelの方も直近1時間じゃ正直大したことはできないので、本格的な運用を検討する場合、標準で備わっている機能では、正直使い物にはならない(全面的に当てにすることができない)という認識である。まあFreeで使うならこんなもんだろうという気はする。重要なログは、別途DBに流すとかSlackにwebhookで通知するとかして逃がしてあげることを検討したほうがいいような気がする。
6 Analytics 2500イベントまで イベント数の制限はないがサイト数等に制限あり
  • Vercelの場合は計測開始から2500イベント(≒2500アクセス)で打ち切りになるが、Cloudflareにはそうした制限はない。この点だけで個人的にCloudflareに軍配が上がる。(これがきっかけで移行しようと思ったくらいなので)
  • Cloudflare Web Analyticsは、(Cloudflareにプロキシしてるサイトであれば)ボタンポチポチしてるだけで超絶簡単に設定できる一方、Vercelは(非常に簡素なものだが)コードを書く必要があるという点で、手間がかからないという面でもCloudflareが僅かながら上を行く。
  • 過去の履歴でいうと、Cloudflare Web AnalyticsもVercel Analyticsも、過去30日までという範囲は同じ。
  • ただ、画面の見栄えや操作性は、個人的にはCloudflare Web AnalyticsよりVercel Analyticsのほうが若干好み。でも僅差。個人的に求める「直感的なUI」という点ではどっちも同様で、それほど大差はない。
7 キャッシュ read 1,000,000/write 200,000 512MB
  • ドキュメント読む限り、VercelのほうはApp Router限定で、readとwriteが分かれており、それぞれ1=8KBを指し、かつFunctionを実行するリージョンによって制限が変わるようだ。上はTOKYOリージョンのもの。なんだか複雑だ。Vercelのダッシュボードとか見てもイマイチわからない。ただ潤沢にありそうだということはわかる。一方のCloudflareは、Freeプランだと512MBという情報が出てきたが、これは「1ファイルの」制限であって、全体としての制限は存在しないというようなことが、コミュニティredditで確認できており、つまり実質無制限にキャッシュ可能、ということか?つよい。まあCDNの範疇ではCloudflareに勝つのは無理か。。。
  • Vercelにキャッシュさせる場合、その指示はコードで書くことになるようだが、キャッシュヘッダを書けばいいというわけでもなさそうで、少し面倒くさそう。対してCloudflareはCache Ruleの画面をポチポチして設定することになるわけだが、コード書かなくて済む分個人的にはこっちのほうがわかりやすい。総合的に見てこの分野ではCloudflareが勝る。ただCloudflareのほうは設定可能な項目が山ほどあるので、アプリケーションごとにどれが必要か等の整理が必要であり、Vercelとは違う意味の面倒くささ(複雑さ)がある。個人的にはCloudflare PagesのCache Rule設定はこちらの記事が詳しい。
8 カスタムドメイン 50/project 100/project
  • 設定できるカスタムドメインの数でいえばCloudflare Pagesのほうが2倍もある。けどそんなにいるか??って感じがするけど。。個人的には10もあれば十分…まあつまりどっちも十分な量を備えているという感想。
  • ちなみに、個人的に良い機能だなと思っている、デフォルトドメイン→カスタムドメインへの自動リダイレクト機能は、Vercel・Cloudflare両者に備わっている。Vercelはここ、Cloudflare Pagesはここで、それぞれ解説されている。ただCloudflare Pagesのほうはドキュメントの記載が古いのか「Bulk Redirect」(日本語だと「一括リダイレクト」)の案内が間違ってて、こっちのドキュメント読んだ方が確実である。
  • Preview環境に対するカスタムドメインの割り当ては、Vercelは標準機能で備わっているが、Cloudflare Pagesには(調べた限り)少なくとも標準機能には同機能は備わっていない。Cloudflare PagesのカスタムドメインはProduction環境(につながるブランチの*.pages.devのCNAME)にのみ対応している。その点ではVercelが勝る。ただ、調べた感じ、こういう記事は見つけたので、ちょっと工夫すればCloudflare Pagesでも出来なくはなさそうである。(やったことないからわからない)
  • wwwドメインをAPEXドメインにリダイレクトさせる機能は両方に備わっている。Vercelはここで、Cloudflare Pagesはここで、それぞれ解説されている。ただ、Vercelの場合は設定単位が各プロジェクト(カスタムドメイン)ごとであるのに対し、Cloudflare PagesはBulk Redirect(日本語だと「一括リダイレクト」)を使うことになるので、設定単位は「アカウント」になる。このためCloudflare Pagesの場合にwwwのリダイレクトを実現させようとすると、同じアカウント内で使う「一括リダイレクト」の総数と競合することになる。その点では(制限の有無を考えれば)Vercelのほうが使い勝手がいい。
9 TLS 1.2~1.3 1.0~1.3(※)
  • VercelもCloudflare Pagesも、デフォルトドメイン(Vercel:*-projects.vercel.app、Cloudflare Pages:*.pages.dev)にHTTPでアクセスすると、自動的にHTTPSにリダイレクトされるっぽい。つまり実質HTTP不可。(封印されている)
  • カスタムドメインの場合、Cloudflare Pagesは同様の動き(HTTP→HTTPS自動リダイレクト)をしたが、Cloudflare Pagesではカスタムドメインの設定時にSSLの設定を施す箇所がなく(自動的・強制的にSSL ONになる)、そもそもHTTPの選択の余地が存在しない気がする。Cloudflare以外をDNSに使う場合はそういう設定が出てくるのかな?やったことないので不明。Vercelの場合はカスタムドメイン設定時にSSL証明書の生成をチャレンジする(ってここに書いてある)ので、そこで生成できてればSSL可能になり、かつHTTP→HTTPSリダイレクトも勝手に実装される。逆にいうとSSL証明書生成できないとカスタムドメイン経由ではHTTPオンリーになる?のかな。まあ普通に考えればそうだがVercelでカスタムドメインのSSL証明書未設定だった時代がないのでどういう動きするのかわからない。
  • TLSのバージョンは、Vercelは1.2~1.3Cloudflare Pagesは1.0~1.3まで、のようだ。(※)ただCloudflare Pagesは1.0は後程無効化可能1.3は別途有効化が必要なようだ。
10 通知 Slackで通知可能 Eメールで通知可能
  • Vercelにはdeployの結果をslackに通知してくれる機能がある。Cloudflare Pagesにも同じような機能はあるが、通知先は(調べた限りでは)Eメールのみが対象になっており、Slackは指定できないっぽい。まあ何かしらの形で通知が来ればよし。本番環境はともかく(カスタムドメイン指定してるからすぐわかる)、preview環境は、毎回ランダムな文字列でURLがつけられるので、この機能がないと、毎回ダッシュボード見に行かないとURLがわからないのが地味に面倒だった。まあ、どっちもできますという話です。
11 Preview環境へのアクセス制限 可能 可能
  • VercelのほうはVercel Authenticationで、Cloudflare PagesのほうはCloudflare Accessという機能で、それぞれ実現可能である。ただ実態となる制御方式は、VercelはVercel自体への認証情報を使うのに対し、Cloudflare PagesはEメールで毎回指定することになるので、利便性の面でいうとVercelのが使い勝手がいい気はする。

学んだこと

getStaticPathsfs.readFileSync使ってた処理のgenerateParamsへの移行方法

現行はgetStaticPathsでサーバー上のJSONファイル(リポジトリに含めてある資産)を読み込んでページビルドしていたのだが、これをgenerateParamsに移行するにあたってどうすりゃいいんだろ?と思ってたところ、 export runtime = 'edge';しない限りは 別に普通にfs.readFileSyncが使えるので、あんまり悩むこともなかった。ちなみにexport runtime = 'edge';するとfsモジュールが使えなくなるのでビルドに失敗する。ここは正直よくわかってないんだが、exportするruntimeの値により、実行ランタイム上に備わっているNode.jsのAPIの利用有無があるようで、edgeだとその利用機能が絞られ、その中にfsが入っていないから、ということのようだ。(だったらなんのために使うんだ?ってのを理解してないが)この辺になんかそれっぽいことが書いてある。一方、JSONファイルなら、fs.readFileSyncなんかしなくても普通にimportで読み込みもできるので、edgeでも特に問題ないかもしれない。ここは試してないのでよくわからない。

Next.jsの公式ドキュメントは、Prerenderingにおいて実行する処理がすべてfetchの例しかなく、サーバー上の静的ファイルを読み込んでSSGページを生成する例が皆無で、どうすりゃいいのか最初は全くわからなかった。JSONファイルはともかく、mdファイル用意してgenerateParamsで読み込んでサブパスのページデータ作るみたいのは、Next.jsでブログ作るユースケースなんかでは普通にありそうだが、こういうの公式でサンプルとしてカバーしてあげないのだろうか。最近のトレンドよく知らないのだが、そもそも、SSGの文脈でも、Prerenderingでfetchなんかするの??(そのためだけにわざわざ外部に静的コンテンツをホストすんの??と思うと疑問なんだが)

SSGページの移行におけるsrc/app配下のpage.tsxとReact Componentとの関係

Pages RouterのSSGページをApp Routerに移行するにあたって、最終的にApp Routerのsrc/app配下のpage.tsxは、主に

  1. (ページ生成に必要な)データをかき集める処理
  2. かき集めたデータをReact Componentに渡す処理

の2つだけを実装すればよいのだ。で、1.のポイントは、

  • exportつけずに個別のfunctionとして定義する(asyncつける/つけないは自由)、exportつけるとビルドで怒られる(App Routerのpage.tsxは、多分関数型React Componentのexport以外(仮にdefaultついてなくても)みとめてない)
  • export const runtimeの値によるが)割と自由にいろんな処理が書ける(fs.readFileSyncでリポジトリに含まれる静的ファイル読み込んだり、fetchで外部サービスからデータとってきたり)
  • この考え方はgenerateParamsを使う場合も同様で、「動的パス」にあたるパラメータの生成を、fs.readFileSyncとかfetchとかを駆使して行うわけだが、generateParamsがそれを担う(generateParams内にその処理を書く)
  • そもそもこの処理自体必須ではない(ページ生成にデータが必要なければわざわざ定義する必要がない)

だ。で、2.のポイントは、

  • 基本的にはがりがりしたRenderの処理はすべて外だしのReact Component(多分慣例的にはsrc/components配下)に移して、page.tsxからは単にそれをimportして使うだけにする、その際に1.でかき集めたデータをpropsとして渡す
  • 重要なのは、React Component側でデータをかき集める処理まで実装しない こと。ここは多分ある程度明確に責任分解されていて、page.tsxはデータ収集してそれを「渡す」ところまでを、src/components配下のReact Componentは「(事前にかき集められた)データが渡される前提」で、「渡されたデータを使ってRenderingする」までを、それぞれ担務する。後者(React Component側)は、ページの都合で動的にデータ取得するなどの目的で「データかき集める」部分も担えるが(fetchつかってAPIでJSON取って描画とか)、少なくとも昔の(Pages Routerの時代の)SSG的な考え方に倣うと、その主体はあくまでpage.tsx側にある。
  • ただし、非常に単純なページ(React Componentがページの生成に外部から引数やデータを必要としないレベルのもの)なら、わざわざ外だしのReact Componentにしてそれimportする必要はなく、page.tsxexport defaultの関数型React Componentのreturn句に、直接それをがりがり書いてもいい

だ。いや、実際の設計思想はどうか知らんけどこういうもんだと理解した。おそらくApp Routerはこういう風に作るように設計されている。と思う。多分。

SSGページを移行する先のReact ComponentはClient Component化が必須

上の続きというか関連なのだが、上の流れで呼び出されることになるReact Component(Pages Router時代のrenderの処理)は、 Client Componentにする必要があるということだ。 これは確かに移行ガイドにもそう書いてあるんだけど、そのrenderの処理内に、useStateuseEffectなど、Client Component必須の要素が仮に一切入ってなくても、Client Componentにする必要がある。Client Componentにしないとビルド時に実際、以下のようなエラーが出る:

...
Error occurred prerendering page "/hogehoge/01". Read more: https://nextjs.org/docs/messages/prerender-error

Error: Event handlers cannot be passed to Client Component props.
  {className: ..., onClick: function S, style: ..., title: ..., children: ...}
                            ^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.
...

これは個人的には納得いってないというか、どうしてこういう制限になるのかよく理解できていない。単独のReact ComponentでuseStateとか使ってガリガリ処理するタイプのComponentはClient Componentにしろという指示は理解できる…が、「Pages Router時代にgetStatisPaths(とgetStaticProps)を使ってページ生成していたが、各ページではuseStateなどのApp Router時代におけるClient Componentの条件を満たすComponentは使用していない」にも関わらず、App RouterでgenerateParams経由で複数ページ生成する場合、Client Component化が必須になるらしい。なぜだ??普通にServer Componentではだめなのか。別にClient Componentが嫌ってわけではないんだが、条件から外れてるにも関わらずフレームワーク都合でClient Component化を強制されてるようで、気持ち悪い。仕組みや仕様がわかれば納得できるのかもしれないが、少なくとも現時点ではピンときていない。

Cloudflare R2とS3の比較

今回、画像置き場を、S3(+Cloudflare)からCloudflare R2に移行したので、そこに関する比較内容のちょっとした感想を。

漫画画像をアップロードするツール、既存で使っている自作のS3にアップロードするロジック(pythonでboto3つかってるだけだが…)と、ほぼ同じ仕組みでR2にもアップロードしたわけだが、みたかんじS3よりR2へのアップロードのほうが明らかに遅い。だからなんだってこともないんだが、へ~、そうなんだ、という発見。なにが違うんだろうね。

一方、「見る」側の速度(参照;readのほう)は、厳密に計ってないのでわからないが、特に違ってるようには見えなかった。ただ、現行も、S3直ではなくCloudflare経由で配信していたので、キャッシュされてるという意味での違いはおそらくあまりないはず。まぁ少なくとも体感で違いがわからなければ問題にはなるまい。

ただ、キャッシュが切れてるときの画像の読み込み速度(=オリジンからのロード速度)が、体感少し遅くなった気がする。その影響か、たまに画像が表示できない(40x系になる)ケースも観測しており、これはこれであんまりよくない気がすしている(一応「漫画サイト」なので、これは致命的な要素になり得るのだ)。オリジンとしてのR2がS3と比べてあんまり速くないのかな??あるいは↑で書いた画像の最適化の動作仕様がNext.js 12と14で変わったことによる影響かもしれない。今回「画像置き場の変更」と「フレームワークのバージョンアップ」を2つ含んでいるためどっちによる影響かわからない。両方とも少なからず関係しているっていうのが正体である気はするが。今の所それほど頻繁に起きる問題というわけでもなさそうなのと、40x系ならばまだしも「体感的に遅い」だと定量的に問題を評価できないので、本格的な対応には着手していない(というかできない)のだが、気にかかってはいるので、中長期的に改善の余地を探っていきたいと思っているところ。

「プログラマー」と「漫画を描く人」としてのバランスが必要だと悟った話

ここは少しエモい話で、技術的な観点はあまりない。上のreact-swipeable-viewsのdeprecationの話に関係するのだが。

「敵が暗号を喋る」というアイディアに関しては、自分の中ではもっとずっと昔から存在していて、最初期の実装は、「画像をクリックする」」というよりもっと厳密で、areaタグとか使って、敵が暗号喋ってるページ画像のその該当箇所を厳密に座標指定して、そこクリックしたら暗号解読チャレンジできるというようなつくりだった。Next.jsでサイトを作りたいと思うようになって、同じことをやるのがさすがに結構難しそうだと思い(試すのが面倒になった、というのもある)、座標指定はやめて「敵が暗号喋ってるページ画像」であれば、その画像内のどこをクリックしてもチャレンジに挑戦できる、というような作りに変えた。これが現行(今回作り直す前)だった。ただ、よくよく振り返ると、そういう背景があったことを理解している人(おそらくこの世で俺一人)でないと、この挙動の理解がまずないので、その理解を、見ず知らずの、多くの場合ただ「漫画」を読みに来ているだけの人(=「暗号を解きにきている人」ではない)に求めるのは、そもそも無理があるという理解に至った。

個人的には「敵が暗号を喋る」というのは、自分のエンジニア的側面と漫画描く人の側面を両方うまく融合できる、結構いいアイディアだと思っていて、この思いは今でもあまり変わってない。しかし、新都社のコメント読む限り、このアイディアは一般的にはあんまり受け入れられないようだと気づいた。そもそも読みに来てくれる人が残念ながらそこまで多くもないのだが、その「読みに来てくれる人」も、漫画を読みに来たのであって暗号を解きにきたわけでもなく、むしろ暗号化されていて何喋ってるかわからないという状況をストレスに感じる人が少なからずいるようだった。まあ漫画の描き方が悪いというか、そういうのを気にするような描き方を(意図せず)してしまっている可能性があり、これはそのアイディアを形にするだけのスキルが、漫画描きとして備わっていないことに原因がある可能性もあるのだが、少なくとも「何喋ってるか気になる」(そしてそれがわからないことが少なからずストレスになる)というのは、作者の立場ではわからなかったことであり、意外な発見でもあった。

少し言い訳めいたことを釈明させてもらうと、ページ画像をクリックすると、「暗号化後の文字列(=敵がしゃべってるセリフ)」「暗号化アルゴリズム」「鍵」「solt」が全部コピペできる形で表示されるので、ここまでくると 最早これは「暗号」ですらない というか、 表面上暗号後の文字列になってるだけの平文 といっても過言ではないと、個人的には思っており、少しわかる人ならopenssl程度でいくらでも手軽に復号できるし、そうでなくても復号のためのサンプルプログラムを公開してる(暗号チャレンジのモーダルにリンク貼ってる)ので、誰でも復号可能(=何喋ってるか突き止めることは可能)な状態である、という理解でいる。しかし、そこに至るまでのUIがまずあまりイケてない(上述したスワイプとモーダルの仕組み)というのと、仮にそれに気づいたとしても、わざわざその手間かけてまで復号しようとする人があまりいないようだ、というのに気づいた。特に後者に関しては、個人的には「ここまで情報開示してるんだから復号してみればいいのに」という風に思うのだが、それはあくまで技術者的視点での意見であり、どうもマンガ読みに来る人の多くはそこまでの情報があったとしても復号ができないし、仮にできるスキルがっても複合しない、というのが、おそらく現実なのだ。それには「わざわざ復号したいと思えるほど面白い漫画でもない」という側面も残念ながら恐らく多分にあるはずだが、その辺も含めて総合的にいって、「復号」までたどりつこうという人があまりいない。チャレンジで答え合わせをしていないだけで、水面下では復号してる人は結構いるかもしれないが(チャレンジされないとその記録(ログ)はこっちではわからない)、とりあえずこのアイディアを読者と共感するという構造自体が、少なくとも俺の今のこの漫画では結構難しい、という理解に至った。

少し大げさに言うと、 プログラマーとしての興味や思いが、漫画作品としての一面を殺してしまった例 だと、今さらながら感じる。漫画方面の方は特にそのスキル不足も関係してる可能性があるが、いずれにしても、全く性質の異なる2つの特性をうまく融合できなかったという事例だろう。特に「UIがイケてない」という部分は、(今考えると)特にUserability上必須でもないモーダルやスワイプを付け加えたりして、「コーディングしたかった」が思いとして勝っており、漫画作品を「魅せる」ための工夫や思いは、無視してはいないのだが少し度外視していた節はある。また、「暗号でもなんでもない、解こうと思えば解けるでしょ」というのも、実にありきたりなエンジニア的な視点と言い分であり、想定されるユーザーの実情を全く考えられていない。「エンジニアはビジネスサイドのことを把握しておくべき」という言説をTwitterでよく見かけるが、 少し内容は違うがそれと近い性質の課題だと思う。こういうのは通常、エンジニアサイドとビジネスサイドという、2つの異なる組織で起きる問題だが、この漫画サイトは両方自分でやってるので、その辺のバランス取りが結構難しいのだ。サイトの実装など、技術的な側面でいえば、正直にいってプログラマーとしての趣味や興味が勝る傾向が多く、漫画作品のことは優先度低めにされがちなので、今後はその辺をちゃんと考えていかないとな…と思ったのだった。

おわりに

というわけで思った以上の苦労があったがなんとか移行完了した。しばらくこれで様子を見て、必要に応じて適宜改善を加えていく予定。それではRESIGN THREATを今後ともよろしくお願いします。