• 2024.log

    2024/12/29

    激動の一年でした。毎年そう言って激動慣れをしようという魂胆です。did0esの2024年をおさらいしていきます。1月Meguro.es主催としての初仕事fuyaさんから引き継いだ Meguro.es で、4年ぶりのミートアップを開催しました。https://x.com/meguroes/status/1741411277090722174併せてWebサイトのリニューアルを、同僚の lanberb とやりました。https://x.com/did0es/status/1741413110890807509ミートアップの会場とケータリングは、株式会社サイバーエージェント様からご提供いただきました。登壇いただいた方々もありがとうございました。当日、参加者の方から「ミニJSConfだ!」とのご意見をいただき、質の高い登壇をおこなっていただけた反面、ハードルが上がりすぎてしまったなと反省していました。LT会(sadnessOjisanのチャーハン会)sadnessOjisanさんからお誘いいただき、Jxckさん主催のLT会に参加しました。心を込めてJS ASTをTraverseする話をしてきました。https://x.com/0xjj_official/status/1748319673429492020LTの傍ら、sadnessOjisanはひたすら中華鍋を振っていました。後日腱鞘炎になって業務に支障をきたさないか、労災は降りるのかなどの心配をしていましたが、杞憂に終わってよかったです。ここで色んな同業者の方々とのつながりができたのも嬉しかったです。謝謝。その他皇居にお花畑があるのを発見しました。https://x.com/did0es/status/17514739985114034022月6年ぶりN回目のフットサル高校2年で派手に燃え尽きてから遠のいていた球技をやりました。写真の手前の人物は会社の同僚で、この後両足を捻挫します。https://x.com/did0es/status/1752999740575477918TSKaigi Proposal 落選てんでダメでした。かなりオタクな話をやろうとしたが、万人受けも意識した結果、どっちつかずのプロポーザルが生まれてしまったと思っています。悔しいにぼおそらくこの言葉が通じる人のほうが少ないと思いますが、あの「にぼ」がなんと関東に進出してきました。ちなみに、この数カ月後に博多ラーメンのお店に生まれ変わります。まさかラーメンから諸行無常を感じるとは思わなかった。その他うちのワンコと彼女とともに、僕の故郷の京都を旅行しました。道中、岡崎公園で野生のストゼロを見つけました。https://x.com/did0es/status/1757279339073126776SUPER BEAVERというバンドの武道館公演を観に行きました。意外と武道館の周りの傾斜が激しかったです。https://x.com/did0es/status/17609295520697633283月モヒカン犬うちのワンコを信じて送り出した結果、モヒカンになって帰ってきました。しばらく近所の海外の方からBeckhamと呼ばれていました。フリーキックが上手そうです。Meguro.es #27株式会社オロ様にケータリングと会場をご提供いただき、正真正銘の目黒区開催が実現しました。当日ご参加いただいた方々もありがとうございました。https://x.com/meguroes/status/17721866622543055504月業務で初Golang同僚のsivchariというGoのエキスパートの手ほどきを受けながら、CLIをGoで書いていました。最終的にはOSSとしてアウトプットできました。https://github.com/CyberAgent/moldableOSS貢献業務で出くわした不具合修正としての、OSS貢献をやりました。StorybookのNext.jsのインテグレーションを軽く直しました。https://github.com/storybookjs/storybook/pull/26787その他今度は家の近所で、野生のメガジョッキを発見しました。巣立ちの直後かもしれませんね。5月業務で立て込んでいる中、実家に帰ったり、Meguro.cssに登壇したりしていました。X(以下、Twitter)に内容のある書き込みがあまりないので、おそらく忙しかったのだと思われます。6月足首の靭帯負傷日頃の行いが悪かったようで、フットサル中に負傷しました。人生初松葉杖でバリアフリーの大切さを実感しました(小池百合子並感)。7月Meguro.es #28アマゾンウェブサービスジャパン合同会社様に会場提供いただきました。当日参加された方々もありがとうございました。https://x.com/did0es/status/1808028008688898182はてブコメントの洗礼はてブというサービスがあります。サービスに対して特に意見はないんですが、このユーザーの中には「3行以上の文章を読めないが、タイピングだけは上手な方」がちらほらいらっしゃるようで、そういった方々が僕の個人的なブログの記事に寄って集って、好き放題タイピング練習をして去っていきました。ぜひ、こちらの釈明記事をご覧になって、今度は3行以上の文章を読む練習をしていただきたいものです。https://blog.did0.es/entries/6331b1af-81e1-4ca3-ad71-a23eb3011c8d今となっては虫の羽音ぐらいの不快感ですが、このときはかなりメンタルがやられました。その他うちのワンコの去勢手術後に遺伝的な異常(おそらく血友病)が見つかり、止血困難とクリニックから連絡があり、夜間病院に連れて行って夜通し看病していました。幸い大事には至りませんでしたが、自分のことかのように非常にショックで、数日間眠れませんでした。持病として受け入れて今は過ごしています。全体的に厳し目の出来事が重なった月で心身ともに疲弊し、ここからしばらくコミュニティ活動をお休みするなど、ご迷惑をおかけいたしました。8月CreatorsVision参加LY主催のエンジニア・デザイナー向けのイベントにご招待いただき、参加していました。potato4dさんには学生の頃からお世話になっています。ありがとうございます。当日、参加したら昔一緒に仕事を回していた方とばったり再会して驚きました。その他VRoid Studioで3Dモデルを作りました。昔pixivでインターンした際に少しだけ開発に触れた(触れたと言ってもコード見たぐらいですが)サービスで、実際に使ってみるとかなり体験の良いもので良かったです。この3Dモデルの用途は後述します。また、ワンコが復活してきたのでようやく一緒に出かけられるようになりました。ワンコの体調と自分の体調がリンクしているのかというくらい、みるみる自分も元気になりました。9月WDC2024登壇初カンファレンス登壇でした。対ありでした。https://speakerdeck.com/shuta13/nazekuraudosabisude-web-konsoruwoti-gong-surunokaロッキンひたちなか内容と気温ともに激アツでした。PIXIV DEV MEETUP 2024参加ご招待いただき参加してきました。逆に顔見知りしかいなくて驚きました。pixivインターン時代にお世話になった f_subal さんともお話できて良かったです。ご招待いただいた petamoriken さん、ありがとうございました。その他縄文時代のstyled-componentsしか書けなくて、業務で苦しんでいました。10月CADC2024 LPリリーステックリードとして CADC 2024 の LP を開発しました。かなり頑張りました。今年はCanvas芸人から身を引いて、リードエンジニアの職務をしつつインフラ構築をやっていましたが、結局Canvas芸もやりました。このときの自分をポケモンのタイプに例えると水タイプでした。https://cadc.cyberagent.co.jp/2024/https://x.com/did0es/status/1843211806116131116その他ジャンプルーキーでとんでもない作品と出会いました。https://rookie.shonenjump.com/series/zGZPbQ8IN-I幼い頃から応援している京都サンガF.C.が復調してきて嬉しかったです。ラファエル・エリアスありがとう。埼京線がりんかい線であることに気づきました。https://x.com/did0es/status/185141369764801348611月国立競技場でサッカー観戦町田ゼルビアの試合にご招待いただいたので、見てきました。アリアンツ・アレーナかと思った。Twitchチャンネルの収益化学生の頃に作ったTwitchのチャンネルを収益化しました。前述したVRoidはここで使っています。つまり…?これ以上は勘弁してください。Twitchは収益化の条件がゆるいと言われているものの、力を入れ始めてからかれこれ半年から1年くらいはかかりました。地道な積み重ねで運を掴みました。フォロワーは現時点で80名程度のかなり小規模のコミュニティですが、よく観に来てくれる人や一緒になにか配信してくれる人ができてよかったです。所属するコミュニティはなんぼあってもいいですからね。その他山梨でキャンプの帰りにラーメン会のNVIDIAを食べました。依存性への懸念から、条例で23区内に作ることを禁止しているのかというほどの僻地にしかありませんが、また行きたいです。先にZodがあって、そこからTypeScriptが生まれた説を唱えていました。https://x.com/did0es/status/1861708132608884949あと、基本的に風邪を引いてました。全てに対する免疫がほしいです。12月サイバーエージェントのNext ExpertsになりましたTypeScript やっていき太郎として、頑張ります。https://blog.did0.es/entries/1608709b-0da6-80f1-91df-c97272e11bf1その他クリスマスイブにアマン東京のアフタヌーンティーに行きました。何もマナーを知らなかったので、怒られないように隅っこでじっとしていました。あと、この記事を書いています。基本的に風邪を引いてました。おわりに「月」って漢字を見すぎて、高校の文化祭の準備ぶりにゲシュタルト崩壊を実感しました。来年も引き続きdid0esをよろしくお願いします。
  • サイバーエージェントのTypeScriptのNext Expertsになりました

    2024/12/18

    今月からサイバーエージェントのTypeScriptのNext Expertsに就任したので、そのご報告記事です。Next ExpertsとはサイバーエージェントのDeveloper Experts制度における、次世代のDeveloper Expertsを目指すポジションです。(https://www.cyberagent.co.jp/techinfo/info/detail/id=31305 より引用)社外にNext Expertsのメンバー一覧の公開はないので、傍から見ると自称エキスパートの一般男性となりますが、Developer Expertsとして認定をいただき、内外から認めていただけるように継続してアウトプットと周囲への還元をやっていきます。なぜNext Expertsになったのか22新卒入社の同期でGoのNext Expertsをやっている、sivchariに強く影響を受けたためです。彼のように活動の幅を広げていきたく、社内の制度を通じて成長の機会を得ようと思いました。また、以前から個人的にOSS 活動(PoimandresのOSSのメンテナーや、その他コントリビューター)やコミュニティ活動(Meguro.es の主催)などを行っており、サイバーエージェントからサポートを受けつつ社内に知見を還元し、今後もこれらを継続していくためでもあります。今後の展望去年度のTSKaigiはプロポーザルを通せなかったので、今年度はリベンジしようと思っています。また、OSS活動もかなりご無沙汰になっている部分があるので、しっかり筋を通して頑張っていきます。また、Meguro.es を継続しつつ、より大きなイベントにも挑戦したく思っています。現場の中で活躍しているエンジニアが、社外へ発信するきっかけとなり、業界全体が活性化するようなカンファレンスを画策しています。名前は…現場の「中」から取って「Inside 〇〇 」のようなフォーマットはどうでしょうか。この名前にピンと来た方は、ぜひご連絡ください(僕からもご連絡差し上げるかもしれません)
  • みなさんがインターネット広告を悪者に仕立て上げなくなるまで〇分かかりました

    2024/09/23

    最近のインターネット広告の酷さには目を見張るものがあるので、こういう文書を書いている。インターネット広告の意義メディアとしてWebサイトやアプリに掲載し、消費者に物事を訴求して購買意欲を刺激する。現状インターネット広告を消費者に対する嫌がらせに用いて、金を稼ぐ商売が横行しているように見受けられる。例えば以下のような広告がある。動画配信サービスの飛ばせない広告ソシャゲの課金アイテムを得るための広告アドブロッカーを購入させるために表示する内容が不快な広告また、以下のような広告の閲覧数や閲覧時間を稼ぐための手法も散見される。ブラウザバックをハックスクロールに追従視認しづらく、押しづらい閉じるボタン他には、他の表示をブロックして動画を読み込むものや、勝手に音が出るものなど、売上にどう繋がるのか全く想像できない、むしろただ体験を悪化させているだけのものもある。何が問題なのか以下2つ。1つ目は、広告を「消費者が見たくないもの」に仕立て上げているところ。広告を利用して消費者にストレスを与え、これを解消するために金を払わせるような仕組みがあらゆるサービスで見受けられる。広告の本来の意義に反しており、広告をまともに消費者に見てもらえない状況を作り出している元凶。しまいには単に不快なだけの広告を作り、アドブロッカーを購入させようとする商売まで現れ始めている。2つ目は、広告の質の低さを誤魔化すために、技術を悪用しているところ。広告の内容ではなく、とにかく消費者の目に入れさせるための方法の模索に傾倒してしまっている。この方法が技術的に正しいものであれば良いが、ブラウザバックで広告表示や、閉じるボタンの視認性や操作性を落とすなど、明らかに体験を損なうものが採られることが多い。ユーザーアクションをハックして、無理くり(しかも質の低い)広告を見せることで、どういった印象を与えるかは考えるまでもないと思う。こういう輩が増えてくるとブラウザベンダーや各種ワーキンググループが対策に乗り出してブラウザに制限を加え、我々開発者の肩身がどんどん狭くなっていく。おわりに特にこれと言って出来ることはないので、これ以上は罪を重ねない方がいいと思いますとしか言えない。
  • WebGLについて1分で話せませんでした

    2024/09/08

    Web Developer Conference 2024 の「1分 de Web 標準」で WebGL について1分で話そうとして話せませんでした。どういった内容を話したのか、話せなかった内容も込みで書きます。WebGL とはGPU による計算と CPU による制御を行い、Canvas 要素の Context に結果を出力するための仕組みです。計算は GLSL というシェーダー言語で、制御は JS で WebGL API を介して行います。仕様The Khronos Group の WebGL Working Group が管理しています。Web 標準だから W3C が仕様策定しているに違いないと思っていましたが、調べたら全然違いました(The Khronos Group は W3C と協力関係にはあるようです)。WebGL Specification (WebGL 1.0)WebGL 2.0 Specificationオリジナルの仕様は Mozilla が策定しており、現在の Working Group に対しても Mozilla のメンバーが関わっています。WebGL と OpenGLWebGL は OpenGL ES( OpenGL Embedded Systems ) に基づいています。OpenGL ES とは、拡張し様々な環境向きに拡張された OpenGL の規格です。適合試験さえ通ればどういった実装でも OpenGL ES 準拠を謳えたため、あらゆるデバイスやプラットフォーム向けの開発 が進みました。WebGL 1.0 は OpenGL ES 2.0 に、WebGL 2.0 は OpenGL ES 3.0 に基づいています。API が完全に OpenGL ES と同一ではない理由の1つとして、ブラウザ特有のセキュリティ対策を行うための拡張を行っていることが挙げられます。例えば、WebGL でテクスチャを扱う際、異なるオリジンの画像の読み込みは CORS によって制限をかけるようになっています。また、初期の WebGL 1.0 では GPU の不具合を利用した Dos 攻撃が可能であったため、対策向けの API も追加されています。なぜ WebGL が使われるようになったのかブラウザから OS の API をコールしないようにWebGL が登場する以前から、ブラウザでハードウェアアクセラレーションは可能でした。しかし、OS によってハードウェアアクセラレーション向けの API は異なります。どの環境でも問題なく動作させるために、OpenGL のような統一の規格を取り入れようという動きがあり、仕様策定に至りました。ちなみに、WebGL 2.0 から OpenGL だけでなく DirectX のサポート も行っています。Flash の衰退Adobe Flash Player が多くの脆弱性を抱えていながら、自動更新をなかなかサポートしなかったことや、Apple のデバイスでは Flash を一切サポートしなかったことから、徐々に衰退し 2021 年には廃止に追い込まれました。その一方で、ECMAScript 準拠の ActionScript と同じようなインターフェースで WebGL を扱えるライブラリの台頭によって、WebGL は開発者に受け入れられていきました。用途Flash がカバーしていた範囲とほとんど同じです。また、ブラウザで GPU を扱えることから、機械学習に用いるケースもあります。おわりにWebGL を使ってなにかやってみた系の記事や資料は多いですが、こういった仕様をまとめたものはあまりなかったので、資料を作っている側としても新鮮で楽しかったです。
  • 2024/07/05の記事の話

    2024/07/06

    2024/07/05 の記事 について、予想外の反響を頂いて驚いている。ここには十分に書けていなかった、なぜこの記事を書いたのかの動機の部分を話したい。別にこのブログには広告もなく、訪問者からなんらかの形で収益を得る方法もなく、正直 PV 数はどうでもいいので、気になった人以外読まなくて良い。あと、先日の記事に意見あればこちらから説明したいし、疑問があれば解消させていただきたいが、いかんせん相互にやりとりできないようなサービスで発言されても反応できないので、全て答えきれないかもしれないが SNS などでお願いします。本文1番目の動機は、疲れていたので愚痴りたかった、以上です。2番目の動機は、僕が情報系の大学を出てソフトウェアエンジニアになった際に感じた、エンジニアリングに対する違和感を言語化し、これからエンジニアを目指す学生(やそれ以前の若い世代)が同じような違和感を抱いたときに、見れば納得できるものを提示したかったためです。僕は学生の頃、情報理工学を専攻しつつ電子工作やDTM(作曲)、Web系の開発などを趣味とするエンジニアでした。大学生になってから初めて情報科学やプログラミングに触れたため、はやく周りとの差を埋めないと!と、毎日がむしゃらになって取り組んでいました。入学当初は、独学でやっていくつもりでしたが競い合えるような環境が欲しくなり、学内の情報系のサークルの門をたたきました。ここで、先のブログで述べた「無邪気なエンジニアリング」をたくさんやって、実績も実力も兼ね備えた友人や先輩たちと出会い、徐々に自身のキャリアが形成されていきました。よくお世話になった先輩は、Vim というエディタ関連の OSS コミッターの方、ハードからソフトまであらゆる開発をこなす器用な方、全くソースコードはかけないがパワーポイントや文章をつくるのが上手な方など、多種多様な分野から色々な物事を教えてくださいました。全く違う分野の中でも共通していたのが「何かしらの軸を1つもってそれを尖らしている人は学生であるなしにかかわらず活躍できている」でした。これは直接誰かが言っていたわけでもないですがふと立ち返ると、サークルや大学内で凄いなーと思っていた方は、何か1つをまず極めてから色々なものに手を伸ばしている印象でした。僕は Web 系の開発に楽しみを見出していたので、Web 開発向けの言語やライブラリ、チームでの Web 開発に詳しくなって学内外で活躍しようと思いたちました。特に、ものを使うだけでなく、その仕組みまで理解することを大学の授業やサークルでも叩き込まれていたので、これを意識して立ち回ってました。この仕組みを理解しようとする立ち回り方が、冒頭で出した違和感の話に繋がります。社会に出るまで、好んで使っていた React の内部実装を読んで勉強会を開いてみたり、Three.js やその周辺の OSS を読んでコントリビュートしてみたり、アニメーションや 3D を盛り込んだ LP を作ってみたり、自分が思うがままの Web 開発をやってきました。就活は、これまで作ってきたものや、在学中に行っていた Web SIer でのバイトや身内で回していた受託業務、経験してきたインターンでやったことなどをお話して、特に問題なく終わりました。内定後は、内定先の子会社でアルバイトしていました。このアルバイトでは、学生の頃から特にやり方を変えることなく振る舞い、それなりに仕事をこなせていたように思っていました。今思えば、ここで目標設定の難しさに触れていたような気がしますが、社会人として、しかも好きなソフトウェアエンジニアリングだけやってお金をもらえる喜びでかき消されていました。入社1〜2年目はひたすら目標設定の難しさに打ちひしがれていました。ここで思ったことを率直に話すと「エンジニアになりたいです!って言ったとき、誰もこんな話教えてくれなかった」です。エンジニアたるもの自分で学べ!と言われそうですが、入ってそうそう「じゃあ今期はどういった成果を上げて、これを通してどう成長する?」と言われて、ささっと手を動かせるような器用な人間ではありませんでした。むしろまるで「今まで積み重ねてきた技術を使いこなして成果を生む」とは真逆で「全く知らない技術をそれなりに使えるようになって、成果を生む」ように見受けられました。「バックエンドをよく知らない人にバックエンドを任せられるのか?」みたく、なぜ未熟な技術を持ってして、少しでも障害を起こしてはいけないサービスを作るのか、頭の中が疑問符でいっぱいでした。そうこうしているうちに適応するのが人間で「自分がこれまでに身に着けたと思っていた技術も、実は知らない点があったかもしれない」と、ある種の自己暗示で成長をアピールする方向に移りました。また、定量化できないとなにも成果を伝えられないので、実装した UI やこなしたタスクは逐一カウントしてまとめることに時間を割き始めました。改善策も、1人で技術的にこうすれば正しい!では全く評価に繋がらないので、ビジネス側の方とリーダーに説明して、いわゆる根回しを行うようになっていきました。そうこうしているうちに3年目となり今に至ります。これが思い描いていた将来かどうかわかりませんが、いつか「あれやっててよかったな」と言えるように、変わらない日々を過ごしていきたいなと思っています。
  • 無邪気なエンジニアリングができなくなってきた

    2024/07/05

    タイトルの通り。好きでやっているエンジニアがだんだん好きではなくなってきたような気がして、改めて何が起きているのか、思考はまとまらないから箇条書きする。無邪気なエンジニアリングとはコードを読む、書くのIOがとにかくたくさん気になったOSSやサービスはすぐさわる記事や登壇で書く以外のアウトプットもたくさん無邪気なエンジニアリングをして、これになりたかったインターネットで一発当てる著名なOSSのコミッターカンファレンスのプロポーザルをたくさん通す本をたくさん書いているたくさん質の良い記事を書いて凄い PV 数なりたかったのその行く末生活を全てエンジニアリングに捧げようとするあらゆる技術イベントに顔をだそうとする(規模の比較的おおきい)コミュニティを主催する(業務時間でOSS活動できるように)目標設定をひたすらするエンジニアはとにかくコードを読んで書いていればよいか違う会社が評価できなければ意味ない結局何にげんなりしているライフステージが進むにつれてエンジニアリングする時間はほぼ確実に削られていく技術者コミュニティの将来性仕事への熱量の低下おわりに近況報告するつもりが、げんなりした内容になっていて申し訳ない。またエンジニアリングが好きに戻れるように、休み休み1個ずつ向き合おうと思っている。
  • AstroでThree.jsを動かす 手軽 合法

    2024/05/01

    多分これが一番早いと思います な Astro で Three.js を動かす方法を見つけたのでシェアします。方法お好きな package manager で three を Astro プロジェクトにインストールした後、.astro ファイルに script で three のコードを埋め込むだけです。 import XXX from “three”も機能していると思います。<div id="container" /> <style> #container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } </style> <script> import { Mesh, OrthographicCamera, PlaneGeometry, Scene, ShaderMaterial, Vector2, WebGLRenderer } from 'three'; const container = document.getElementById('container'); if (!container) { throw new Error('Canvas Container not found'); } const scene = new Scene(); const camera = new OrthographicCamera(-1, 1, 1, -1, 1, 1000); camera.position.set(0, 0, 100) camera.lookAt(scene.position) const geometry = new PlaneGeometry(2, 2); const material = new ShaderMaterial({ fragmentShader: `uniform vec2 resolution; varying vec2 vUv; void main() { vec2 st = gl_FragCoord.xy / resolution.xy; gl_FragColor = vec4(st.x, st.y, 1.0, 1.0); }`, vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`, uniforms: { resolution: { value: new Vector2(window.innerWidth * window.devicePixelRatio, window.innerHeight * window.devicePixelRatio) } } }); const mesh = new Mesh(geometry, material); scene.add(mesh); const renderer = new WebGLRenderer({ antialias: true, alpha: false, stencil: false, depth: true }); renderer.setClearColor(0x000000); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(window.innerWidth, window.innerHeight); container.appendChild(renderer.domElement); function handleOnResize() { renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(window.innerWidth, window.innerHeight); material.uniforms.resolution.value = new Vector2(window.innerWidth * window.devicePixelRatio, window.innerHeight * window.devicePixelRatio); camera.updateProjectionMatrix(); } handleOnResize(); window.addEventListener('resize', handleOnResize); function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); } animate(); </script>信憑性担保のため、StackBlitz にコード全体を置いています。did0es の言うことを信じられない方はぜひご覧ください。補足: TypeScriptの扱いscript にインラインで埋め込むと、Linter で TypeScript のパースに失敗することがあります。その場合、インラインではなく .ts ファイルに内容を切り出して、 script の src 属性で読み込むようにしてください(StackBlitz 反映済み)。<script src="./_three.ts" />いかがでしたか?素敵なGWをお過ごしください。
  • React v19アナウンスざっくりまとめ

    2024/04/26

    React v19(Beta) リリースアナウンス を(自分用に)まとめる。雑なのでご指摘ウェルカムです。新規ActionsいままでAPIリクエストの状態、エラーを取りつつ、楽観的更新(更新している間UIは更新後の値をだすアレ、swrとかからの輸入)などを手動で行っていたのを以下のAPIで隠蔽できるようにRSCserver components: canaryで構築されていたreactベースのFWとの互換性持たせたserver actions: clientの中にserverのコード埋め込むアレ改善refそのままpropsで渡せるよ(forwardRefいらなくなる)hydrationエラーの詳細でるようになるよ ヤッター!Context が Context.Provider の代わりになるrefでcleanupできるuseDefferedValue に初期値はいるreact-helmet みたく metadataをreturnの中に書けるようにstyleの読み込まれる順番を制御できるように重複したスタイルは読み込まないようにasync な script を重複して読み込まないようにmetadata を API 使って関数で コンポーネントのトップレベル で呼べるよ3rd partyのhtml埋め込みとかで起きていたhydrationエラーを抑制(放置してくれる)エラーが重複したらまとめるCustom Elementサポートアプグレhttps://react.dev/blog/2024/04/25/react-19-upgrade-guide みて所感Next.jsあたりが先行して18 canaryを取り入れまくったので既知なものが多かった改善系でおや?となる部分と助かる〜となる部分が混ざっていた
  • Web Speed Hackathon 2024でやったこと(作問者視点)

    2024/03/25

    3/25, 26の2日間でWeb Speed Hackathon 2024が開催されました。今年は参加者ではなく、作問者としてサーバーサイドの開発をメインに携わりました。問題のどの部分を担当したのかは、当日の解説スライドで一部言及いただいていましたが、他に伝えきれなかった部分をここにまとめています。担当した作問サーバーサイド・インフラ→クライアントサイド→Canvas芸→テストで Finish でした。初期デプロイ環境整備「とにかく Node.js で SSR している激重アプリを Docker で動かしたい」という要望に答えるためにサービスを調査していました。個人的には、サイバーエージェントでインフラの組織にいるのもあって、名前こそ色々と聞いていましたが、いまいち試せていなかったKaaS(Kubernetes as a Service)の検証をしたく、oktetoというサービスを選定しました。後に render → koyeb → (koyeb で当日トラブル) → render という流れでデプロイ先は変わることとなります。k8sのマニフェストを初めてちゃんと書いたので、良い経験になりました。ちなみに、似たような構成で検証用に作ったものを Hono のアドカレ3日目の記事として公開したりしています。 Hono を Docker や k8s 環境で動かすユースケースを紹介したので、ぜひご観覧ください。サーバーサイドの設計と実装isucon13 の Node.js 実装で Hono が使われた話 を聞いて、絶対 Hono でやってやろうという感じでした。最初は isucon よろしく生 SQL を書きまくっていましたが、流石に Web 向けのイベントなので、途中で ORM (Drizzle) で書き換えを行いました。Drizzle を選んだ理由は、Drizzle に Hono の例が存在しており、これはかなり参考にできそうだったためです。また、ライブラリ紹介ページが面白いのも理由の1つでした。実は最初は Clean Architecture っぽい感じで、 repos → interface → adapter → usecase → view みたく書いていたんですが、認知負荷が上がりすぎたゆえに、後ほどリードエンジニアの方にかなりのリファクタを行っていただき、お見せできるくらいになったと思います。バンドラの変更(rspack to tsup)実は WSH 2024 の最初の実装は rspack でバンドルされていました。しかし、React が production mode で満足に動かない問題が生じ、一旦乗り換えることとなります。ここで universal に使えるバンドラを探した結果 tsup となりました。これはめちゃくちゃ後付けなんですが、esbuildではなくて tsup にした理由は vite に移行しやすいためです。vite に変えると code splitting などができるようになるので、これを意識していました(選定時はそこまで見えていなかった)。個人的に egoist さんの OSS が好きなのもあります。デプロイ環境変更(okteto to render to koyeb)okteto の無料プランが 3/19 に終了してしまい、泣く泣く他のサービスへの移行を行いました。選ばれたのは Render でした。例年通り Fly.io でも良かったんですが、2024 のアプリはメモリの消費量が凄まじく 256 で耐えられそうになかったので、Render を使う流れになりました。しかしビルド速度が思った以上に出ず、 後ほど Koyeb というサービスで置き換えられます。クライアントサイドのバンドルサイズ増量例年通りの polyfill と富豪的にリソースを使うライブラリの導入をしました。以下を入れておいたら結構な方がすぐに気づいて剥がしていたので笑いました、流石です。core-js@webcomponents/webcomponentsjsregenerator-runtimelodashunderscorees5-shim, es6-shim, es7-shimunormimmutablemoment-timezonethreeCanvas芸(HeroImage)「GLSL で object-fit: cover したら面白そう」この思いが怪物を生み出しました。過去の出題でobject-fit: coverをcanvasのcontextでやるような試みがありましたが、それの変化球です。真っ先に駆除される「やられ役」として、中ボスくらいの強さを用意したつもりでしたが、意外と手こずっている方がいて嬉しかったです。重くてもここは VRT 回したほうがいいです。ちなみに、当初は object-cover のすべての値を GLSL で網羅しようとしていましたが、時間の関係上盛り込まずにお蔵入りしました。以下で供養します。const objectFitNoneShader = `uniform sampler2D tImage; varying vec2 vUv; void main() { vec2 uv = vec2( vUv.x * float(textureSize(tImage, 0).x), vUv.y * float(textureSize(tImage, 0).y) ); vec4 color = texture2D(tImage, uv); vec2 maskUv = 2.0 * vUv - 1.0; float mask = step(length(maskUv), 1.0); gl_FragColor = mask * color; }`; const objectFitCoverShader = `uniform sampler2D tImage; varying vec2 vUv; void main() { float aspectRatio = float(textureSize(tImage, 0).x / textureSize(tImage, 0).y); vec2 uv = vec2( (vUv.x - 0.5) / min(aspectRatio, 1.0) + 0.5, (vUv.y - 0.5) / min(1.0 / aspectRatio, 1.0) + 0.5 ); vec4 color = texture2D(tImage, uv); vec2 maskUv = 2.0 * vUv - 1.0; float mask = step(length(maskUv), 1.0); gl_FragColor = mask * color; }`; const objectFitContainShader = `uniform sampler2D tImage; varying vec2 vUv; void main() { float aspectRatio = float(textureSize(tImage, 0).x / textureSize(tImage, 0).y); vec2 uv = vec2( (vUv.x - 0.5) / max(aspectRatio, 1.0) + 0.5, (vUv.y - 0.5) / max(1.0 / aspectRatio, 1.0) + 0.5 ); vec4 color = texture2D(tImage, uv); vec2 maskUv = 2.0 * vUv - 1.0; float mask = step(length(maskUv), 1.0); gl_FragColor = mask * color; }`; const objectFitScaleDownShader = `uniform sampler2D tImage; uniform vec2 imageResolution; uniform vec2 resolution; varying vec2 vUv; void main() { float imageAspect = imageResolution.x / imageResolution.y; float screenAspect = resolution.x / resolution.y; vec2 uv = gl_FragCoord.xy / resolution; if (imageAspect < screenAspect) { uv.y = (uv.y - 0.5) * (imageAspect / screenAspect) + 0.5; } else { uv.x = (uv.x - 0.5) * (screenAspect / imageAspect) + 0.5; } vec4 color = texture2D(tImage, uv); vec2 maskUv = 2.0 * vUv - 1.0; float mask = step(length(maskUv), 1.0); gl_FragColor = mask * color; }`; const objectFitFillShader = `uniform sampler2D tImage; varying vec2 vUv; void main() { vec4 color = texture2D(tImage, vUv); vec2 maskUv = 2.0 * vUv - 1.0; float mask = step(length(maskUv), 1.0); gl_FragColor = mask * color; }`;E2E・VRTの一部主に VRT を書いていました。Playwright で E2E すると良いぞとのお告げを各所で聞いていたので、今回触れることができてよかったです。ただ、 waitFor が要素の width, height を見ていることで、何回かハマったケースがありました。意外と E2E って書くの難しいですね。おわりに実装の振り返りとしては、まず第一に、無料で Docker イメージを放り投げて動かしてくれるサービスは皆偉大と思いました。クラウドサービスを提供している部署にいるのもあって、無料で提供することの難しさと、そのありがたみを実感しています。また、WSH を通して Node.js と TypeScript を用いたバックエンドの実装にガッツリ関われたのがよかったです。日頃は Web フロントエンドを軸に活動しているので、今回の実装を通して視座が上がりました。あとは Canvas 芸を仕込むことができたのもよかったです。昔から Web で 3D をこねくり回すのが好きだったので、これを何かしらの成果物に変えられて満足しています。まだWSH2024の問題を解かれていない方は、ぜひ以下のレポジトリから取り組んでみてください。docsに計測用のLighthouseの値の傾斜なども記載されているので、お手元でWSHができるようになっています。また、解説動画も併せてご覧ください。レポジトリ:https://github.com/CyberAgentHack/web-speed-hackathon-2024解説動画:https://youtu.be/RwQC8eQzeyI?si=5xKf7vRtoUs72annWSHは、2021に学生版に参加してまぐれで2位を取った以来は特に泣かず飛ばずで、今回作問の話が来た際はかなり驚きました。弊社は本当にやりたいやりたいと言っていると、やらせてもらえる環境でよかったです。ちなみに、WSH 2024が始まる直前にずっと受け取りそこねていたWSH 2021準優勝のトロフィーを渡されてウケました。良い思い出になりました。同じような思い出を得たい方はサイバーエージェントにいらしてください。We are hiring!
  • SSGにNext.js以外を使わなくてもいい時代に、私は、Vikeを使いたいのです

    2024/02/14

    「なぜSSGにVikeを使うのか」というタイトルにしたかったんですが、ゼクシィのCM構文が流行っているような気がして、乗っかってみました。あの雑誌を読んだことがないので、どういった層をターゲットに作られているのかかなり気になっています。Vikeについてここでは紹介しないので、適宜 https://vike.dev を見てください。いつの間にか vite-plugin-ssr が名前を変えてマイティ・ソーになっていました。なぜSSGにVikeを使うのか簡素よくある Next.js is too much for us というモチベです。fetchのキャッシュまで望んでいなかったり、案外作りたいものはfile based routingさえ枠組みがあれば他は自分で組みたいし、複雑なものは提供してほしくない気持ちがあります。一方で、Next.jsを使うならば、なるべくNext.jsのやり方に従っておきたい気持ちもあります。SSG(SG)に関しては、Pages Router, App Router双方で可能ですが、今になって前者を使い続ける新鮮味のなさと、後者の不安定さの板挟みという感じでした。後述しますが、全部Next.jsのやり方に従うとSG程度でもそこそこの規模のWebプロジェクトになることもあり、他の策を求めていました。これは Meguroes リニューアルの裏側 で触れていますが、もちろんVike以外も検討しています。例えば、11tyは筋の良いライブラリと思ったんですが、テンプレートエンジン次第でビルド部分の設定がかなり複雑になりそうで諦めました。Gatsbyは安定こそしているものの、GraphQLサーバーがくっついてくることから、Next.js同様にオーバースペック感が否めませんでした。VikeはViteのプラグインとして作られているので、vite.config.jsに少し変更を加えるだけでSSGできます。import { defineConfig } from "vite"; import vike from "vike/plugin"; export default defineConfig({ plugins: [vike({ prerender: true })], });一度 esbuild でローカルをESM使って開発する体験を経てしまうと離れられないかつ、設定をごちゃごちゃと書きたくない自分にとってはかなり嬉しいです。また、サーバーはNode.jsに限らず選べるようになっており、もしGraphQLがほしいなどあれば後からでも付け足せます。ただ、デフォルトは Node.js で、Next.js のような API Routes(Route Handlers) もついていないような、非常に簡素なものとなっています。SSR部分に手を加えやすいこれは SSR を隠蔽したい人には刺さらないです。僕にとっては、Vike で機能を削った Next.js Pages Router の SG モードのようなものを目指して、HTML に注入するデータの形をいじったり、pre-rendering 時の処理を変えたりしたかったので、非常に手が加えやすいもので良いです。例えばPreactで使う際は、以下のように +onRenderHtml.ts で renderToString を使い、onRenderClient.ts でHydrationを行います。/** onRenderHtml.ts */ const onRenderHtml: OnRenderHtmlAsync = async ( pageContext, ): ReturnType<OnRenderHtmlAsync> => { const { Page, pageProps } = pageContext; if (!Page) throw new Error("My render() hook expects pageContext.Page to be defined"); const pageHtml = renderToString( <Providers pageContext={pageContext}> <Page {...pageProps} /> </Providers>, ); // +data.tsでfetchingしたものをここでHTMLに注入する。 const title = pageContext.data?.title || DEFAULT_TITLE; const documentHtml = escapeInject`<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8" /> <link rel="icon" href="/favicon.svg" /> <title>${title}</title> </head> <body> <div id="root">${dangerouslySkipEscape(pageHtml)}</div> </body> </html>`; return { documentHtml, pageContext: {}, }; }; // --- /** onRenderClient.ts */ const onRenderClient: OnRenderClientAsync = async ( pageContext, ): ReturnType<OnRenderClientAsync> => { const { Page, pageProps } = pageContext; if (!Page) throw new Error( "Client-side render() hook expects pageContext.Page to be defined", ); const root = document.getElementById("root"); if (!root) throw new Error("DOM element #root not found"); hydrate( <Providers pageContext={pageContext}> <Page {...pageProps} /> </Providers>, root, ); document.title = pageContext.data?.title || DEFAULT_TITLE; };また、僕は以下のように、Next.js Pages Routerの機能をVikeの機能に落とし込んでみました(左がNext.jsで右がVikeです)。NextPage : +Page.tsx getStaticProps : +data.ts getStaticPaths : +onBeforePrerenderStart.tsVikeは使う機能を選択できるので、ここの柔軟さが良いですね。Next.jsだと、これってCSRだっけ?SSRだっけ?それともSSGだっけ??みたいなことが起こり得るので、Vikeで余分な情報を削ぎ落とした状態で、誰でもキャッチアップしやすくなると思います。JSXで書けるもはや好みの世界ですが、テンプレートエンジンとして JSX 以外を使うモチベがもうありません。11tyを使わなかった理由としても述べてきました。じゃあJSXで書けるならもう Next.js でいいじゃん?となるとは思います。これに対しては、Reactの新たな機能をいち早く使うため、Next.jsを使うことはありますが、SGに果たしてどこまで今のReactの思想が反映できるのかなとは思ってます。求めることは JSX を HTML に変換して、初期状態の描画さえ行えていれば後は適当に client side で少し見た目を変える程度なので、おそらく Suspense 以後の React の新機能はあまり使う場面がないでしょう。テンプレートエンジンとして React を使う程度に収めたいという需要に、Vikeは十分応えられるものだと思います。おわりにRemix?Astro?知らない子ですねあわせて読みたいhttps://leerob.io/blog/using-nextjshttps://www.epicweb.dev/why-i-wont-use-nextjs
  • JS・TS向けビルドツールは結局何を使えばいいのか

    2024/02/06

    ※ 主にブラウザで動かすためのバンドルについてなので、Nodeなどの環境に関しては言及していないRustやGoによるツールの書き換えが流行って久しいが、JS・TS向けビルドツールにおいてはまだ最適解が見つからない、あるいは最適ではないが現状これでいいが確立されてきつつあるので、今一度どういったツールが出ていて今後どうなっていくのか簡単に書き置きした。現状個人的に考える、現状これ使っておけばいいツール。ほぼViteだが、好みや場面で他も使う。完全に新規アプリケーション:Vite, rspackライブラリ:Vite, tsup既存の置き換えwebpackを使用しているwebpack以外:Viteツールごとの所感esbuildそのまま使えるのが理想ではあるが…個人プロジェクト故の懸念Vitehmrある。jsもhmrできるesbuildではなくてrolluprspackWebpack互換production readyという感じはまだないtsupesbuildのラッパーrollup相当がほしいならもはやこれでいい感TurbopackWebpackの後継(を名乗っている)Next.js以外で使えるようになってから次第Farm(farm-fe)“build tool written in rust, go” でググったら見つけたrspackよりも速いことを謳っているesbuildラッパーではないが、Vite互換swcVite入れられないプロジェクトに入ってる印象トランスパイラではあるが、一応言及したい今後Vite中心で回っていくesbuild直の敷居は高いVite以外Native ESM 時代 が…→ 来るバンドルしていない esm でいいなら esbuild で dev prod も1つにCDN Edgeで esm 吐くものを動かす→ まだ来ないViteやrspackで現状維持してrolldownを待つおわりにそもそもJS・TS周りのツール実装にJS以外の言語を使う意義wasm吐き出すならまだしも、バイナリ吐き出すために使う意味 書き換えに頓挫しているものもあるいつでも剥がせるようにしておこうね(ビルドツールに限らずではあるが)4年前から言われてるNative ESM時代が来る保証はないが、備えるのは◯
  • Meguro.esリニューアルの裏側

    2024/01/15

    はじめにこの記事では、Meguro.es 再始動後どのようにリニューアルを行ったのか、リニューアル告知記事で触れられなかった内容を中心にお話していきます。主にWeb開発の技術と生成AI寄りの話になると思います。リニューアルの背景前主催の fuya さんから主催を引き継いだ際、ロゴやコンセプトなど、Meguro.es のブランドに関わる部分すべての刷新を快諾いただき、リニューアルに踏み出しました。引き継ぎの時点ではデザイナーが不在で手をこまねいていたところ、同僚かつ目黒区民の lanberb がいたので、スカウトしてデザイナー兼運営として招き入れました。社内では、よく彼とツーマンセルでアレコレ開発に関与しています。そこに前運営から2名(fuya さん、mogamin さん)が加わり、計4名でMeguro.esは再始動しました。ロゴリニューアル今回特筆すべきものとして、生成AIでたたき台となるロゴを作ったことが挙げられます。ロゴリニューアルでは、はじめに lanberb が生成AIの出力を加工してパターン出しを行い、運営メンバーで良さげなものを選びました。ロゴとしては中段の右から2番目のもので決まりました。僕的には今までのロゴの要素をを受け継ぎつつ、新たなコミュニティの誕生を表現したかったのでかなりしっくり来ています。同様に、ロゴの色出しも進めました。ここはかなり悩みましたが、目黒感を全面に押し出すべく、目黒区のシンボルから色を拝借したものを選びました。リスの色に用いた目黒区の紋章の紫は、落ち着きと鮮やかさを兼ね備えおり、目黒川の桜をイメージしたピンク色とも馴染んでいます。こういった流れで、ひとまずロゴのリブランディングが完了しました。これは Meguro.es 運営のふわっとした方針でもあるんですが、膨大なリソースをコミュニティに割かないでも回せるようにすることを目指していたので、生成AIの活用がちょうどハマった場面でした。Webサイトリニューアルロゴの次に lanberb にWebサイトのUIのデザインを制作してもらいました。僕はピクセルパーフェクトが苦手なので、UI面の実装は lanberb にも入ってもらいました。お陰で、年内滑り込みで公開まで漕ぎ着けました。クオリティとしては、実装したかった機能はすべて実装でき、あとは軽微なデザイン修正があるような状態です。採用した技術は、告知記事にあるとおり Vite・Preact です。SSGのフレームワークに Vike を使い、Cloudflare Pagesにデプロイしています。従来は Nuxt で SSG したものを Netlify から配信していたので、技術は変われどやっていることは同じです。また、Contentful と Webサイト をつなぐバックエンドに Hono を使い、Cloudflare Workersにデプロイしています。なぜ新たにバックエンドを用意したのかについては後述します。ここでは、告知記事で触れられなかった「Vikeを採用した背景」「プロジェクトの構成」「バックエンド」の3つをお話します。Vikeを採用した背景手短に話すと以下の通りです。UIをJSXで書けるNext.js・Gatsby.js以外でSSGに挑戦したいビルドの設定が複雑ではない開発サーバーが Vite(esbuild) なので速いUIを JSX で書ける SSG 向けフレームワークとして、 Gatsby.js や Next.js がよく用いられると思います。Gatsby.js は GraphQL がくっついた SSG ツールで、Next.js はWebサイトというよりもWebアプリケーションを作るためのツールで、オプションで SSG ができるというものです。Meguro.es もこのどちらかを使えば、僕が慣れているのもあって早く開発できたんですが、いわゆる「強くてニューゲーム」になってしまいかねなかったので採用は見送りました。そこで最初に白羽の矢を立てたのは 11ty です。かねてより SSG フレームワークで良さそうだなと思っていたものの、触れることなくここまで来たので、この機会に色々と触ってみました。11ty はテンプレートエンジンであれば、ほぼ任意の言語で UI をかけます。が、11tyとしては Mozilla が作った DSL である、Nunjucks を使ってねという感じでした(11tyに出会うまで僕は知らなかった)。別に、テンプレートエンジンにそこまでこだわりはなかったのですがテンプレートエンジンや DSL に関しては文法を覚える手間が惜しいので、慣れているものを使いたい独自のファイル形式( .njk )で、サポートしているエディタが限られるNunjucks のやってることはテンプレート + コードのコンポーネント化にすぎない以上で、Nunjucks の使用は見送りました。Nunjuck がだめだと言うわけではなく今回のニーズに合わなかっただけなので、また機会があればこれで 11ty を使ってみようと思います。Nunjucks のかわりに JSX を使うことになったのですが、11ty の dev tool のファイル監視・webpack による JSX(TSX)のトランスパイル・TailwindCSSのバンドルがうまく噛み合わず、プロダクションのビルドはできるものの、HMR の有効化で苦戦しました。実装は残ってないんですが https://github.com/jahilldev/11tyby を参考にしています。ちらっと見ていただくとわかるとおり、開発サーバー起動や、ビルドは若干複雑です。特に、最近 11ty が 2.0 にバージョンがあがったこともあり、なかなか最新のAPIと併せて JSX を組み込めずにいました。あと、久々に webpack 使ったんですが、相変わらず Dev Server は動作が重たいなーと思いました。他には issue に esbuild を使う方法 が上がっていたので真似してみたんですが、11tyのレイアウトを使う方法が見つからず、こちらはこちらで苦戦を強いられました。HMR は動いたので、どうにかレイアウトを11ty側のビルドツールに食わせられれば開発はできるかもです。ただ、期日的に厳しいので断念しました。Vikeとはここらへんで一旦ビルドの設定はシンプルに書きたくなったので、esbuild を使った流れから Vite 周辺を探り始めました。ようやく Vike にたどり着きます。Vike自体、前々(vite-plugin-ssrの頃)から知っていたのですが、11tyと同様触れたことのない技術でした。Vike は Vite で SSR するためのライブラリですが、SSG もサポートしています。PreactとVikeでSSGする場合、以下のように Vite の config file を書くだけで終わりなので、11tyでつらかった部分はこれで解消しました。import { defineConfig } from "vite"; import preact from "@preact/preset-vite"; import vike from "vike/plugin"; export default defineConfig({ plugins: [preact(), vike({ prerender: true })], });では、Vike の機能についていくつか触れながら、どういった機能や構成で要件を実現したのか紹介します。Vike の設定として、/renderer というディレクトリに +onRenderHtml.ts ファイルを置いて SSR の処理を書きます。React や Preact だと、ここで renderToString を呼び出します。const onRenderHtml: OnRenderHtmlAsync = async ( pageContext, ): ReturnType<OnRenderHtmlAsync> => { const { Page, pageProps } = pageContext; if (!Page) throw new Error("My render() hook expects pageContext.Page to be defined"); const pageHtml = renderToString( <Providers pageContext={pageContext}> <Page {...pageProps} /> </Providers>, ); // +data.ts(後述)がreturnしたものをここでHTMLに注入する。 const title = pageContext.data?.title || DEFAULT_TITLE; const desc = pageContext.data?.description || DEFAULT_DESCRIPTION; const ogImageUrl = pageContext.data?.ogImageUrl || ${WEBSITE_URL}/image_og.png; const robotsContent = pageContext.data?.isPrivate ? "none" : "index,follow"; const documentHtml = escapeInject`<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8" /> <link rel="icon" href="/favicon.svg" /> <title>${title}</title> </head> <body> <div id="root">${dangerouslySkipEscape(pageHtml)}</div> </body> </html>`; return { documentHtml, pageContext: {}, }; };また、 +onRenderClient.ts ファイルを置いて、Hydrate の処理を書きます。const onRenderClient: OnRenderClientAsync = async ( pageContext, ): ReturnType<OnRenderClientAsync> => { const { Page, pageProps } = pageContext; if (!Page) throw new Error( "Client-side render() hook expects pageContext.Page to be defined", ); const root = document.getElementById("root"); if (!root) throw new Error("DOM element #root not found"); hydrate( <Providers pageContext={pageContext}> <Page {...pageProps} /> </Providers>, root, ); document.title = pageContext.data?.title || DEFAULT_TITLE; document .querySelector('meta[name="description"]') ?.setAttribute( "content", pageContext.data?.description || DEFAULT_DESCRIPTION, ); };レンダリング後に必要な、ページで共通のレイアウトやロジックはここで適用します。SSR・Hydrate は Node.js で書いていますが、他の言語やライブラリに変えることもできます。データを JSX に注入してHTMLの生成まで、細かく柔軟にできるのが良いですね。Vike の決まりとして /pages 内の +Page ファイルを持つディレクトリがルーティングに対応します。Next.js の Pages Router と似たものです。この /pages 内のディレクトリでは、サーバーサイドとクライアントサイドの処理を別ファイルで扱います。例えば / ページの場合、 /index/+data.ts がサーバーサイドで /index/+Page.tsx がクライアントサイドのファイルです。ちなみに SSG のパス生成も別ファイルで、 /index/+onBeforePrerenderStart.ts となります。Next.js の Pages Router と照らし合わせると、以下と対応します。NextPage : +Page.tsx getServerSideProps, getStaticProps : +data.ts getStaticPaths : +onBeforePrerenderStart.tsVikeはNext.jsより後にできているのもあって、ある程度Next.jsのやり方に倣っていると言えます。Next.jsに親しみのある人からすると、新たに覚えることは少ないかつ、SSR周りの処理に手を加えやすいので、痒いところに手が届く感じがあります。プロジェクトの構成Vike の規則に従いつつ、ディレクトリ構成やデータの取得フローなどは僕が思うベストプラクティスを実践しました。ディレクトリ構成は以下になります。作るものはWebアプリというよりもブログに近いので、機能別に分けたりはしていないです。. ├── README.md ├── dist ├── public ├── src    ├── components # UIコンポーネント    ├── hooks # Custom Hooks    ├── libs # Architectureを表現するディレクトリ + utility    │   ├── entities # Entity層    │   ├── presenters # Presenter(Output)層    │   ├── repositories # Repository層    │   └── usecases # Usecase層    ├── pages    ├── renderer    ├── styles    └── typesデータ取得は Clean Architecture を意識して設計しました。以下取得の流れです。取得系以外ないので、Controllerに相当する層はありません。Repository層でContentfulのJS SDKのAPIを呼び出し、そのレスポンスをPresenterで整形、Usecaseを介してUIのHTMLに注入するような流れです。実装の詳細は libs ディレクトリ内をご覧ください。小技の紹介TypeScript関連でちょっとした小技を使用し、開発を円滑化しました。Brand型で「HTMLの混ざった文章からHTMLを取り除いた文字列型」を表現しました。/** @see https://basarat.gitbook.io/typescript/main-1/nominaltyping#using-interfaces */ export type Brand<T, U extends ${string}Brand> = T & { [_ in U]: never };export type StringWithoutHtml = Brand<string, "stringWithoutHtmlBrand">; const htmlRegExp = /<\/?+(>|$)/g; function isStringWithoutHtml(str: string): str is StringWithoutHtml { return !str.match(htmlRegExp); } export function parseStringWithoutHtml(str: string) { const parsed = str.replace(htmlRegExp, ""); return isStringWithoutHtml(parsed) ? parsed : ("" as StringWithoutHtml); }OG Description など、純粋なテキストがほしい箇所に対してこの型を当て、型レベルで parseStringWithoutHtml 関数の使用を強制しています。 // Entity export interface Fuga { fields: { // og: { title: string; description: StringWithoutHtml; }; }; } // Presenter export class FugaOutput implements Fuga { readonly fields: Fuga["fields"]; constructor({ fields }: Entries"items") { this.fields = { // og: { title: String(fields.title || ""), description: parseStringWithoutHtml( String(fields.summary || "").replace(/\n/g, ""), ), }, }; } }これで「特定の関数で文字列を変えてね!」と口頭で説明する手間を省くことができました。また、Result型でSSR時のエラーハンドリングを容易にしています。export type Result<R, E extends Error> = Ok<R> | Err<E>; export class Ok<R> { readonly #value: R; constructor(readonly res: R) { this.#value = res; } public isOk(): this is Ok<R> { return true; } public isErr(): this is Err<never> { return false; } public unwrap(): R { return this.#value; } } export class Err<E extends Error> { readonly #value: E; constructor(readonly err: E) { this.#value = err; } public isOk(): this is Ok<never> { return false; } public isErr(): this is Err<E> { return true; } public unwrap(): E { return this.#value; } }+data.ts で Result が Ok か Err かを判別し、Err であれば vike/abort でエラー内容をクライアントに返しています。import { render } from "vike/abort"; import { getFoo } from "~/libs/usecases/getFoo"; export async function data() { const fooRes = await getFoo(); if (fooRes.isOk()) { return { foo: fooRes.unwrap(), }; } throw render(503, fooRes.unwrap().message); // Err だと unswap すると Error オブジェクトになる } // uescase export const getFoo = async () => { const res = await foo.find(); return res; }; // repository export const foo = { find: async (): Promise<Result<Foo, Error>> => { try { const foo = await client.getEntries({ content_type: "foo", }); return new Ok(new FooOutput(foo.items[0])); } catch (e) { return new Err(e as Error); } }, } as constRepository 層などの UI から遠いところで適当な Error を投げることなく、UIに近いところまでエラー内容を持ってきてから操作できました。他にも色々と Vike + TypeScript で試行錯誤したので、ぜひ https://github.com/meguroes/next.meguro.es からご覧ください。バックエンドCMSがあるのに、なぜバックエンドが必要?となるかと思います。理由としては、Contentfulのアクセストークンをクライアントサイドに露出させないためです。また、Vikeは API Routes 的な機能を持ち合わせていないため、別でバックエンドのサーバーを立てる必要がありました。ContentfulのAPIをブラウザで叩こうとすると、アクセストークンを Authorization ヘッダーにつけることになり、普通にChromeなどのネットワークタブから見えてしまいます。別に Contentful に限ったことではないですが、有効期限切れがほぼないようなトークンは漏れ出してほしくないものです。そこで Proxy の JS サーバーでヘッダーにトークンを埋め込み、APIを呼び出す形にしました。バックエンド実装には Hono を使いました。Hono を用いた動機は、Proxy のサーバーを Cloudflare Workers で動かすためです。また、直近で Hono のアドカレに参加したのもあり、さらなる知見とユースケースの実践の機会を求めていました。今回は Contentful のデータを Workers で取得し、クライアントに返す API を実装しました。Contentful の JS SDK は Workersで(axiosのアダプターがないと)動かないので、簡単のためにContentful Delivery API を直だ叩きしています。なるべく JS SDK と使用感を揃えるべく、以下のMiddlewareを実装し Context に API の fetcher をセットして使いました。app.use("/api/*", async (c, next) => { const { CONTENTFUL_SPACE_ID, CONTENTFUL_ACCESS_TOKEN } = env<Env>(c); const contentful: Contentful = { getEntries: async (queryObject) => { const searchParams = new URLSearchParams(); Object.entries(queryObject).forEach(([key, value]) => { if (!Array.isArray(value)) { searchParams.set(key, value); } else { value.forEach((v) => searchParams.append(key, v)); } }); const query = searchParams.toString(); const res = await fetch( https://cdn.contentful.com/spaces/${CONTENTFUL_SPACE_ID}/entries?${query}, { headers: { Authorization: Bearer ${CONTENTFUL_ACCESS_TOKEN}, }, }, ); const data = await res.json(); return data; }, }; c.set("contentful", contentful); await next(); });import { Hono } from "hono"; const app = new Hono(); app.get("/", async (c) => { const client = c.get("contentful"); const { limit, skip } = c.req.query(); const post = await client.getEntries({ content_type: "post", order: ["-fields.createdAt"], ...(limit && { limit }), ...(skip && { skip }), }); return c.json(post); }); export default app;初期リリースで盛り込まれていた無限ロードのUIにこのAPIが使用されています(今はデザインの都合上消滅しましたが、多分復活します)。さいごにかなりタイトな日程の中で、デザインやイベント向けグッズの発注、connpassのイベントページ作成など、並行してリニューアルを進めてくれた運営メンバーの3名に感謝しています。改めてありがとうございます。特にロゴデザインからのWebサイトデザインまで、息をつく間もなく作業してくれた lanberb には、感謝してもしきれないです。今度ラーメン二郎おごります。鍋二郎でもいいです。ぜひあなたのお家に持ち込んでやらせてください。あと、CSSはもうTailwindCSSで勘弁してください。締めに Meguro.es のミートアップの告知です。2024/01/25 に Abema Towers で約4年ぶりの Meguro.es を開催します。1/18まで抽選枠募集中なので、登録はお早めに!https://meguroes.connpass.com/event/305991/また今回、特別ゲストとして syumai さん・sadnessOjisanさん・arayaさんの3名に、ECMAScript に関連した話題で発表していただきます。発表の内容は connpass に掲載しています。公募の登壇枠も4つ用意しております。初心者から上級者まで、技術のレベル感は問わないのでぜひご応募ください!また、登壇の有無に関わらず、皆さんがミートアップを安心して楽しんでいただけるように、新たなガイドラインのご確認をお願いしています。併せてよろしくお願いします。では、イベント当日にお会いしましょう!ご精読いただきありがとうございました。
  • 2023.log

    2023/12/30

    街女子診断結果はいつも吉祥寺女子の @did0es です。2023年度振り返りです。今、昨日来たばかりの犬が寝ているようで寝ておらず、鼻をスンスン言わせてて気が散っているので、そういう姿勢の文章になります。あと、普通に飲酒しながら書いているので冴えてます。1月Muddy Web #4Preactの差分検出の実装をスタンドアロンで動くようにし、それをEditor.jsというライブラリに移植する話をしました。https://speakerdeck.com/shuta13/technologies-for-developing-editors新卒時から関わっていたエディタライブラリ開発の1つの集大成となりました。初コロ富士急に遊びに行くためチケットを取った際、陰性証明が必要だったので最寄りのPCR検査場に出向いたところ大当たりでした。帰りに一緒に検査した彼女が、いつも通っている道がかなり煙臭いと言っていたのに全く気づかなかったので、結果出るより先にコロのそれなような気がしてました。奇跡的に熱はなく、味覚障害だけがありました。あと、目黒区のコロナ支援物資の中に1本だけ中華ドレッシングが入っていて、なんかウケました。2月デカめのリリース携わっていたサービスでデカめのリリースをしました。IP自体もかなりユーザーの規模がでかく、フロントエンドエンジニアとしてやれるだけのことはやりました。主にCS対応やバグ修正系で Next.js いじりを色々やっていた気がします。フットサル部署の同期と週2でフットサルをやっていました。ハビエル・サネッティ的な動きを意識しています。3月人生初メンター始めて仕事でメンター業をしました。社内のグレード的には新卒に毛が生えた程度だったので務まるか不安でしたが、なんとか周りの力を借りまくってできました。居住区散策目黒近郊を色々と見て回った月でした。4月スラックス前止められない事件全社会があったので、リクルートスーツを引っ張り出したところ、スラックスの前が止められなくて成長を感じました。5月帰省懐かしの京都に帰ってました。伏見稲荷や錦市場にインバウンドたちが帰ってきて、かつての活気が戻ってきた感がありました。京都では地元の友だちと、同志社大学付近のチャイが美味しいシーシャに行ったり、適当に中書島で飲んだりしてました。後ほど禁煙することとなるので、これが最後のシーシャでした。6月CADCCyberAgent Developer Conference というイベントの LP 実装を担当しました。https://cadc.cyberagent.co.jp/2023/WebGLによるGPU関連技術もりもりで、スペックが低い目のPCだと無事見れないLPとなりました。同期の lanberb というデザインもフロントエンドもするスーパーマンと、3DCG導入発案から開発、当日の運用までツーマンセルで動きました。絶望的なCSSを彼に何度も救われつつ、大量の useMemo と useCallback を振り回し、Clean Architectureを用いた策に溺れ、AWS のコンソールを凝視しながら開発を完遂しました。塊魂今でも交流のある幼馴染の家で、親指の皮がめくれるまでPS2のコントローラーのアナログパッドをグリグリして白熱した、塊魂(みんな大好き塊魂)がSwitchで再販されたので、購入してやりまくりました。個人的に焚き火のステージがマジで難しい。塊オンザスウィングを久々に聞いて、危うく涙するところでした。僕が総理大臣になった暁には国歌にしようと思います。7月ゆるキャン読んだことがありませんが、ふもとっぱらで同期たちとキャンプをしました。火遊びができて楽しかったです。また、真っ暗闇でもう包丁を使いたくないなと思いました。結構広めな敷地かつ、芝生が全面に生えていて、そばには深夜に車で通ると楽しい富士山から成る観光地(青木ヶ原樹海)があるので、かなりキャンプにはよかったです。帰りには静岡でサウナに行きました。朦朧(ととのって)家に帰りました。8月異動8/1付けで社内転職の形で異動し、CA本部に出戻りました。異動先はグループIT推進本部の CyberAgent group Infrastructure Unit という社内横断のインフラ組織です。インフラ知識を深めるのと、フロントエンドの規約やアーキテクチャなどの整備からサービス開発に関わりたく、それができそうな環境を求めて異動しました。禁煙私生活では遂に喫煙習慣を絶ちはじめました。彼女に喫煙をやめてくれとかなり強く言われていたので、ついに動き始めました。目黒区から禁煙外来の補助金を受け取って安くで通院して治す予定が、ファイザー社の禁煙補助薬の生産停止を受けて完全に蒸発しました。これまでの失敗から、気合いでどうにかできないのはわかっていたので、禁煙パッチを購入して毎日貼って過ごしてました。このパッチめっちゃ高いんですが、値段相応の効き目あって良かったです。が、もちろんニコチンが入ってるので依存性がある….ところが、ベストタイミング(?)でコロナに罹り、パッチを張る余裕なく突っ伏してたらパッチも要らなくなりました。無事コロナも寛解し、ダブルでお得に命拾いしました。ただ、やはり禁煙に踏み切れたのは環境が変わったからな気がします(部署異動により周りに喫煙者がほぼいなくなった + フルリモートになった)。周りに吸う人がいっぱいいると禁煙に踏み切ったとて、初期フェーズでやらかすのが世の常という感じです。あとは好きな配信者が禁煙して発狂している動画をちらほら見ていました(後者は失敗してますが)。https://www.youtube.com/results?search_query=k4sen+禁煙https://www.youtube.com/results?search_query=加藤純一+禁煙非喫煙者の方々に囲まれて、1人で心細い思いをしながら禁煙をする際の励みになりました。9月UIT Meetup Vol.20登壇しました。https://speakerdeck.com/shuta13/turborepo-code-generationniyoru-saibaezientogurupunohurontoendokai-fa-noxiao-lu-huapotato4d氏からお誘いいただいて、CIUのフロント事情をお話しました。同氏には糊口を凌いでいた学生の頃から様々な面でお世話になっています。ありがとうございます。Muddy Web #6二人三脚であらゆる方向に走ってみている、同期のデザイナー件エンジニアと登壇しました。https://speakerdeck.com/shuta13/cyberagent-developer-conference-cadc-2023-lpkai-fa-nowu-tai-li後半パートを担当し、Momento でリアルタイムアプリケーション開発最高だぜ!という話と併せて、Three.js によるWebサイト実装の細かい部分に触れました。Momento Meetup CADCネタで登壇しました。かなりアットホームなコミュニティで楽しかったです。https://speakerdeck.com/shuta13/cyberagent-developer-conference-cadc-2023-lpkai-fa-nowu-tai-liMomentoはDynamoDB開発者が作ったRedis as a Service のようなもので、インフラを主戦場としないエンジニアにはかなりうってつけのサービスです。禁酒チャレンジ色々飲みの場でやらかした結果、大学時代から苦楽を共にしてきた iPhone XR が大破したので、iPhone 13に買い替えました。なお、このとき iPhone 15 が発表されていたのですが、二日酔いの思考はまともではないので 13 を購入しています。かなりいたく反省し、その後2ヶ月ほど禁酒してみたりして、平常運転に戻っていきました。金銭を気にしなくていい飲みはマラソンみたいなものなので、自分より速い人を追いかけないほうがいいです。10月Go書き初め異動先に、なんと同期のGo Next Expert 太郎が入ってきたので、彼に教えを請いながらGoを学び始めました。彼いわくChatGPTに聞けと常々いっていたのでそうしました。それマー?みたいな感じでGPTに教えを請っていたんですが、実際のところGoのトークンの少なさとGPTはかなり相性良く、何を聞いてもだいたい正解みたいなコードが返ってきてました。JSだとこうはいかない…他にはユニオン型をGoで書こうとして3日くら迷走したのを彼へ渡すと、ものの数時間で書いてきたので泣きながら元のコードを消したりしていました。ライフステージの変化今までは目黒区の某所に1人で住んでいたのですが、この度2人で住み始めました。禁煙してみたり禁酒してみて失敗してみたりしていたのは、これに備えてです。11月Muddy Web #7日経の愉快な仲間たちとMuddy Webでコラボし、僕も登壇しました。https://speakerdeck.com/shuta13/puraibetokuraudonokonsoruhua-mian-wonext-dot-jsnoapp-routerdehururipureisusitahua実はこのイベントには企画段階から噛んでいて、Muddy Webの運営に持ち込んで色々と裏でやっていました。Muddy Webとしては、始めて他社とのコラボになりました。僕は特に運営に関わってこなかったのに、運営ヅラして動くことを許容していただいた関係者の方々には感謝の気持ちでいっぱいです。登壇を終えた際、sadnessOjisanやShinyaigeekに「もっと泥らしい泥もってこい!」と檄を飛ばされたので、次回以降もし登壇の機会があれば、えぐいヘドロを持っていこうと思います。えぐいヘドロってなんかポケモンの持ち物みたいですね。グライオンに持たせてまもる連打したいです。そういえば、アーカイブが公開されているようなので、ぜひご観覧ください。https://cyberagent.connpass.com/event/301089/Meetup 主催前述した Momento Community(もめんと会)のMeetupを代行で主催しました。始めて自社イベントを企画したかつ、ワンオペでMeetupを動かしていたのでかなりキャパってましたが、もめんと会のみなさまからのサポートでなんとか成し遂げられました。Meguro.es 主催になるXで Meguro.es を引き継ぎたい旨を発信していたところ、前主催の方に拾っていただき主催を引き継ぐ運びになりました。もれなく例のCADCデザイナー件エンジニアマンを引き連れ、新たな Meguro.es の運営メンバーとして活動しています。個人的に今後のキャリアプランとしてスペシャリストを目指していたので、コミュニティの主催は願っても見ないような機会になりました。ライフステージの変化実は婚約したりしてました。12月HonoアドカレHonoアドカレの3日目を担当しました。https://blog.did0es.me/entries/c0e07acb-44f4-45f0-9539-b210a6d6163fサーバーレスではなく、コンテナ系のサービスでHonoを動かす若干変わり種の内容です。yusukebeさんには以前お世話になったのと、Honoに興味はあったもののなかなか触れてなかったので、アドカレ執筆を引き受けました。ISUCONでHonoが採用されていたりしたので、今後様々な場面で使われることを想定してk8sで動かす例を作ってみました。みなさま読んでくださると嬉しいです。Go to Korea忘年会で大当たりを引いた結果の胃腸炎で絶不調の中、韓国へ旅行に行きました。想像とは違って、辛いもの以外もかなり美味しかったです。明洞近くの忠武路という場所に宿を取り、二泊三日で色々とソウル近郊を観光しました。梨泰院クラスの撮影地を回れたので良かったです。おれも居酒屋出店して一発当てたかった。ライフステージの変化かねてより計画していたわんちゃんお迎え計画を実行し、男の子のミニチュアシュナウザーをお迎えしました。日々是排泄物処理という感じではあるものの、ペットって本当にかわいいですね。カメラロールがほぼほぼ犬の写真になり、iPhone がスマホではなく犬の写真撮影用ハードウェアになりました。総評以下総評です。スペシャリストのエンジニアを目指して成長できた禁煙だけでめっちゃ書いてて笑う犬によってすべてを赦した来年の抱負CAでExpertになる主催系のコミュニティ運営頑張る体を壊さないで稼ぐ方法を編み出すご精読ありがとうございました。良いお年を。
  • 恐怖!storybook x viteでbuildコケまくり男

    2023/12/07

    storybook v7と vite でレアな(?)バグを踏み抜き、平日全ての時間をドブに捨てたので紹介します ♪ちなみに vite 以外でも、webpack の define プラグインとかでも同様のものが発生しうると思います。概要バグは突如 npx sb init --builder=vite でセットアップしたプロジェクトで発生しました。storybook build でstatic export してデプロイを試みたところ以下に遭遇しました。(ちなみに storybook dev は機嫌よく動いていた)[commonjs--resolver] Unexpected token (10:8) in /Users/XXXX/YYYY/node_modules/doctrine/lib/utility.js file: /Users/XXXX/YYYY/node_modules/doctrine/lib/doctrine.js:10:8 8: 'use strict'; 9: 10: var "0.3.10"; ^ 11: 12: VERSION = require('../package.json').version; => Failed to build the preview SyntaxError: Unexpected token (10:8) in /Users/XXXX/YYYY/node_modules/doctrine/lib/utility.js原因(にたどり着くまで)このエラーについて、GitHubやインターネット中を探し回りましたが何も情報が無く、自力でどうにかするしかないようでした。エラーメッセージでは、doctrine というnpm package内で Unexpected token でコケているようなので、この実装である eslint/doctrine をあたってみました。以下がエラー箇所付近の実装です。(function () { 'use strict'; var VERSION; VERSION = require('../package.json').version; exports.VERSION = VERSION;変わった記述はありませんが、 var が気になりますね。エラーの内容としては、この VERSION が何かしらのタイミングで文字列になってしまい、変数として処理できず Unexpected token になっているようです。ところで、eslint/doctrine はメンテナンスが終了しています。3.0.0 が最後のリリースのようです。https://github.com/eslint/doctrine/releases/tag/v3.0.0… と、先程のエラーを思い出すと違和感に気づかれたかもしれません。 8: 'use strict'; 9: 10: var "0.3.10";"0.3.10" になってないか???え、なんで 3.0.0 じゃなくて 0.3.10 が入るの!?となりますよね。筆者はこのエラーに遭遇して8時間経過してから気づいたので、多分読者の皆様のほうが聡明です。文字列になってしまってることも変ですが、せめて 3.0.0 で上書きされていてほしいものです。ちなみに 0.3.10 とは、筆者がstorybookを使用している環境のpackage.jsonのバージョンです。storybookのビルドに使用している vite の config を見てみます。import path from "path"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tsconfigPaths from "vite-tsconfig-paths"; import svgr from "vite-plugin-svgr"; import pkg from "./package.json"; /** @type {import("vite").UserConfig} */ const config = { build: { lib: { entry: path.resolve(__dirname, "src/index.ts"), formats: ["es", "cjs"], fileName: (format) => ${format}.js, }, outDir: path.resolve(__dirname, "./dist"), sourcemap: true, emptyOutDir: false, rollupOptions: { external: [...Object.keys(pkg.peerDependencies || {}), "react/jsx-runtime"], output: { globals: { react: "React", "react/jsx-runtime": "react/jsx-runtime", "react-dom": "ReactDOM", }, }, }, }, define: { VERSION: JSON.stringify(pkg.version), }, plugins: [tsconfigPaths(), react(), svgr()], }; export default defineConfig(config);define に明らかに怪しいやつがいますね。// define: { VERSION: JSON.stringify(pkg.version), },viteの define で、ライブラリのバージョンを取れるように VERSION プロパティを宣言していたところ、こいつが storybook build の際に doctrine 内部の VERSION を文字列で上書きした結果、冒頭のエラーが発生したようでした。解決方法defineで VERSION を宣言するのをやめました。怒りのコメント付きです。define: { /** * storybook build 時に読み込まれる doctrine というパッケージ内に var VERSION という記述があり * これと同じ名前のプロパティを定義すると衝突するため、VERSION ではなく PKG_VERSION とする。 * @see https://github.com/eslint/doctrine/blob/0e8eba7f80b89cc8185541dda4e90c961d1d3553/lib/utility.js#L10 */ PKG_VERSION: JSON.stringify(pkg.version), },おわりに変数の名前に気をつけなさい、それはいつか衝突するから。まあ特に var は今回関係なかったですね。人の書いたJSで見つけたら過剰反応してしまいがちです。おしまいです。
  • Hono app with Docker, Kubernetes

    2023/12/03

    こちらは Hono Advent Calendar 2023 3日目の記事です。はじめにHonoと聞くと、Cloudflareなどのサーバーレスなサービスでアプリを動かす印象が強いですが、コンテナ系のサービスでも全然開発出来るよーというユースケースの紹介です。アプリを実装するHonoでAPIを、Reactでクライアントを実装し、簡単なTodoアプリを作ってみます。全体は以下のようなモノレポになっています。. ├── README.md ├── apps │   ├── api/ │   └── web/ ├── okteto.yaml ├── package-lock.json └── package.jsonAPI構成はMVCです。app/ ├── Dockerfile ├── dist/ ├── k8s_api.yaml ├── package.json ├── src │   ├── controller │   │   ├── index.ts │   │   └── todo-controller.ts │   ├── index.tsx │   ├── infrastructure │   │   ├── index.ts │   │   ├── todo-database.ts │   │   └── todo.db │   ├── lib │   │   ├── index.ts │   │   ├── result.ts │   │   └── sqlite3.ts │   └── model │   ├── index.ts │   └── todo-model.ts ├── tsconfig.json └── tsup.config.tsDBは以下で初期化しておきます、import { sqlite3 } from "@/lib"; import { Database } from "sqlite3"; import path from "path"; export const TODO_DATABASE_PATH = path.join(__dirname, "todo.db"); export interface TodoInterface { uid: string; title: string; completed: 0 | 1; } export class TodoDatabase { db: Database; constructor() { this.db = new sqlite3.Database(TODO_DATABASE_PATH); this.#creataTable(); } close() { this.db.close(); } #creataTable() { const sql = ` CREATE TABLE IF NOT EXISTS todo ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT, title TEXT, completed INTEGER )`; this.db.run(sql); } }CRUDをそれぞれ書いてみます。Modelimport { TodoInterface, TodoDatabase } from "@/infrastructure"; import { Result } from "@/lib"; export class TodoModel { #todo: TodoDatabase; constructor() { this.#todo = new TodoDatabase(); } read({ uid, title, completed }: Partial<TodoInterface>) { return new Promise<Result<TodoInterface[]>>((resolve) => { let sql = "SELECT * FROM todo"; const params: TodoInterfacekeyof TodoInterface = []; if (uid) { sql += " WHERE uid = ?"; params.push(uid); } if (title) { sql += " WHERE title = ?"; params.push(title); } if (completed) { sql += " WHERE completed = ?"; params.push(completed); } this.#todo.db.all<TodoInterface>(sql, params, (error, rows) => { if (error) resolve({ status: "error", error }); resolve({ status: "ok", data: rows }); }); }); } create({ uid, title }: Pick<TodoInterface, "title" | "uid">) { return new Promise<Result<TodoInterface[]>>((resolve) => { const sql = "INSERT INTO todo (uid, title, completed) VALUES (?, ?, ?)"; this.#todo.db.run(sql, [uid, title, 0], (error) => { if (error) resolve({ status: "error", error }); resolve({ status: "ok", data: [{ uid, title, completed: 0 }] }); }); }); } update({ uid, title, completed }: TodoInterface) { return new Promise<Result<TodoInterface[]>>((resolve) => { const sql = "UPDATE todo SET title = ?, completed = ? WHERE uid = ?"; this.#todo.db.run(sql, [title, completed, uid], (error) => { if (error) resolve({ status: "error", error }); resolve({ status: "ok", data: [{ uid, title, completed }] }); }); }); } delete({ uid }: Pick<TodoInterface, "uid">) { return new Promise<Result<TodoInterface[]>>((resolve) => { const sql = "DELETE FROM todo WHERE uid = ?"; this.#todo.db.run(sql, [uid], (error) => { if (error) resolve({ status: "error", error }); resolve({ status: "ok", data: [] }); }); }); } }Controllerimport { TodoModel } from "@/model"; export class TodoController { #todo: TodoModel; constructor() { this.#todo = new TodoModel(); } async read(params: Parameters<TodoModel["read"]>[0]) { return await this.#todo.read(params); } async create(params: Parameters<TodoModel["create"]>[0]) { return await this.#todo.create(params); } async update(param: Parameters<TodoModel["update"]>[0]) { return await this.#todo.update(param); } async delete(param: Parameters<TodoModel["delete"]>[0]) { return await this.#todo.delete(param); } } export const todoController = new TodoController();今回、HonoはAPIサーバーとして、JSONを返すものとします。ブラウザからAPIを叩くのでHonoのCORS Middlewareも入れておきます。import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { todoController } from "@/controller/todo-controller"; const app = new Hono(); app.use( "/api/*", cors({ origin: ["http://localhost:8080", "https://web-shuta13.cloud.okteto.net"], allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], allowHeaders: ["Content-Type", "Authorization"], }) ); app.get("/", (c) => { return c.text("Health OK"); }); app.get("/api/todo", async (c) => { const todos = await todoController.read({}); if (todos.status === "ok") { return c.json(todos.data); } else { c.status(500); return c.json(todos.error); } }); app.post("/api/todo", async (c) => { const data = await c.req.json(); const uid = crypto.randomUUID(); const result = await todoController.create({ uid, ...data, }); if (result.status === "ok") { return c.json(result.data); } else { c.status(500); return c.json(result.error); } }); app.put("/api/todo/:uid", async (c) => { const uid = c.req.param("uid"); const data = await c.req.json(); const result = await todoController.update({ uid, ...data }); if (result.status === "ok") { return c.json(result.data); } else { c.status(500); return c.json(result.error); } }); app.delete("/api/todo/:uid", async (c) => { const uid = c.req.param("uid"); const result = await todoController.delete({ uid }); if (result.status === "ok") { return c.json(result.data); } else { c.status(500); return c.json(result.error); } }); serve(app, (info) => { console.log(Api server is running on ${info.port}); });こういうライブラリでCRUD書いて試すときに、Todoアプリだと書きやすので本当に例題として優れていますね。Webvite の react-ts のテンプレから生成したものをほぼそのまま使います。web/ ├── Dockerfile ├── dist/ ├── index.html ├── k8s_web.yaml ├── package.json ├── public/ ├── server.js ├── src │   ├── components │   │   └── App.tsx │   ├── main.tsx │   └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.tsApp.tsx にTodoリストと、Todo作成用のフォームを追加します。import { useState, useEffect } from "react"; import { clsx } from "clsx"; interface Todo { uid: string; title: string; completed: 0 | 1; } const API_URL = import.meta.env.PROD ? "https://api-shuta13.cloud.okteto.net" : "http://localhost:3000"; function App() { const [todos, setTodos] = useState<Todo[]>([]); const [title, setTitle] = useState(""); useEffect(() => { fetch(API_URL + "/api/todo") .then((res) => res.json()) .then((data) => setTodos(data)); }, []); return ( <div className="space-y-4 mx-auto max-w-[768px] px-4"> <h1 className="text-2xl font-bold">hono-docker-k8s-example</h1> <div className="space-y-2"> <h2 className="text-xl font-bold">Todo</h2> <ul className="space-y-2"> {todos.map((todo) => ( <li key={todo.uid} className="flex place-items-center place-content-between bg-gray-100 p-2 rounded-md" > <div className="flex gap-x-2"> <input type="checkbox" checked={todo.completed === 1} onChange={async () => { await fetch(API_URL + "/api/todo/" + todo.uid, { method: "PUT", body: JSON.stringify({ title: todo.title, completed: todo.completed === 1 ? 0 : 1, }), }); const res = await fetch(API_URL + "/api/todo"); const data = await res.json(); setTodos(data); }} /> <span className={clsx({ "line-through": todo.completed === 1, })} > {todo.title} </span> </div> <button type="button" onClick={async () => { await fetch(API_URL + "/api/todo/" + todo.uid, { method: "DELETE", }); const res = await fetch(API_URL + "/api/todo"); const data = await res.json(); setTodos(data); }} > Delete </button> </li> ))} </ul> </div> <div className="space-y-2"> <h2 className="text-xl font-bold">Add a new task</h2> <form className="space-y-2" onSubmit={async (e) => { e.preventDefault(); await fetch(API_URL + "/api/todo", { method: "POST", body: JSON.stringify({ title, completed: 0, }), }); setTitle(""); const res = await fetch(API_URL + "/api/todo"); const data = await res.json(); setTodos(data); }} > <label htmlFor="title">Type a title</label> <input name="title" type="text" className="border w-full px-2" onChange={(e) => { setTitle(e.target.value); }} value={title} /> <div className="flex place-content-end"> <button type="submit" className="bg-green-200 px-4 py-1"> Add </button> </div> </form> </div> </div> ); } export default App;ちなみに Web の production server は Hono で立ち上げています。 server.js がこれの実装です。"use strict"; import { Hono } from "hono"; import { serve } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; const app = new Hono(); app.use("/assets/*", serveStatic({ root: "./dist" })); app.use("/vite.svg", serveStatic({ path: "./dist/vite.svg" })); app.use("/", serveStatic({ path: "./dist/index.html" })); serve({ fetch: app.fetch, port: 8080 }, (info) => { console.log(Web server is running on ${info.port}); });Honoでのフロントエンド関連の話デプロイしてみるDockerfileとk8sのマニフェストを書いて、これまでに実装した API と Web をそれぞれpodで動かしてみます。今回はk8sを用いた開発やデプロイ簡略化のために、Oktetoというサービスを使っています。OktetoについてDockerfileHonoはNode.jsの環境があれば動くので、こんな感じで書けば良いです。FROM okteto.dev/node:18 COPY package.json ./ COPY package-lock.json ./ COPY apps/api ./apps/api RUN npm ci . COPY . . RUN npm run build --prefix apps/api ENV PORT 3000 EXPOSE 3000 ENTRYPOINT ["npm", "run", "--prefix", "apps/api"] CMD ["start"]なお、nodeのイメージはoktetoのレジストリに上がっているものを使うので、ローカルで動かす際は適当なものに書き換えてください。Reactの向けのものは以下のようになります。ほぼ一緒です。FROM okteto.dev/node:18 COPY package.json ./ COPY package-lock.json ./ COPY apps/web ./apps/web RUN npm ci . COPY . . RUN npm run build --prefix apps/web ENV PORT 8080 EXPOSE 8080 ENTRYPOINT ["npm", "run", "--prefix", "apps/web"] CMD ["start"]それぞれ、モノレポのWorkspaceのルートに配置しておきます。ManifestsOktetoとk8sのmanifestを書いてみます。今回は、API向けの k8s_api.yaml とWeb向けの k8s_web.yaml に分けて、Okteto CLIでそれぞれを kubectl apply -f で実行してデプロイします。Web 向けの k8s manifest は以下のようになります。apiVersion: apps/v1 kind: Deployment metadata: name: web spec: selector: matchLabels: app: web template: metadata: labels: app: web spec: containers: - image: okteto.dev/node:18 name: web command: ["npm", "run", "--prefix", "apps/web", "start"] --- apiVersion: v1 kind: Service metadata: name: web spec: type: ClusterIP ports: - protocol: TCP name: "web" port: 8080 selector: app: web --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: web annotations: dev.okteto.com/generate-host: web spec: rules: - http: paths: - backend: service: name: web port: number: 8080 path: / pathType: ImplementationSpecificちなみにIngressは別に書かなくても Okteto 側で適当なものを割り当て られます。API 向けの k8s manifest は以下のようになります。apiVersion: apps/v1 kind: Deployment metadata: name: api spec: selector: matchLabels: app: api template: metadata: labels: app: api spec: containers: - image: okteto.dev/node:18 name: api command: ["npm", "run", "--prefix", "apps/api", "start"] --- apiVersion: v1 kind: Service metadata: name: api spec: type: ClusterIP ports: - protocol: TCP name: "api" port: 3000 selector: app: api --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: api annotations: dev.okteto.com/generate-host: api nginx.ingress.kubernetes.io/enable-cors: "true" nginx.ingress.kubernetes.io/cors-allow-origin: "https://web-shuta13.cloud.okteto.net" nginx.ingress.kubernetes.io/cors-allow-methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS" nginx.ingress.kubernetes.io/cors-allow-headers: "Content-Type,Authorization" spec: rules: - http: paths: - backend: service: name: api port: number: 3000 path: / pathType: ImplementationSpecificほぼWebと変わりませんが、Ingressの部分でCORS向けの設定を追加しています。Hono側でCORSは設定しているのですが、これを追記しないと web の k8s のサービス名のオリジン以外は全部弾かれます。次にOkteto manifestです。build: api: image: okteto.dev/node:18 context: . dockerfile: apps/api/Dockerfile web: image: okteto.dev/node:18 context: . dockerfile: apps/web/Dockerfile deploy: - kubectl apply -f ./apps/web/k8s_web.yaml - kubectl apply -f ./apps/api/k8s_api.yaml dev: api: command: bash forward: - 3000:3000 sync: - ./apps/api:/apps/api web: command: bash forward: - 8080:8080 sync: - ./apps/web:/apps/web一応 dev も書いてます。(これでローカルでpod間の通信を見たいときはデプロイ後に okteto up で、podを立ち上げて確認できます)では、イメージをビルドし、Okteto CloudにデプロイしてPod上でアプリを動かしてみます。Okteto CLI をインストールし、以下でOkteto Cloudにデプロイします。okteto login okteto deploy --buildOkteto CloudにAPIとWebでそれぞれpodが起動しています。Web の URL を開くと、アプリも動いてていい感じですねおわりにDockerとk8s、KaaSを使用してHono製のアプリを開発してみました。あまりHonoには触れたことがなかったんですが、Expressを書く感覚で書け、エコシステムやサンプルも充実しており、ビギナーにも優しい印象でした。基本Honoを使う際は、サーバーレスで事足りるとは思いますが、コンテナやk8sが必要になった場面で何かの参考になれば幸いです。今回の記事に登場したソースコードはこちらからご覧になれます:https://github.com/shuta13/hono-docker-k8s-exampleご精読ありがとうございました。次回は4日目 @watany さんの「helmetっぽいミドルウェアの話」 です!
  • 個人ブログのCMSをContentfulからNotionに移行した所感(後悔)

    2023/11/05

    今までは Contentful でこのブログの記事を管理していましたが Notion に移行しました。この記事では移行手順や所感についてお話します。先に所感を話すと、エディタの体験が改善し、従来より執筆モチベは出るようになりました。今まで、なんとなく個人ブログよりも(確実に人の目に触れるのもあって)Zennに書こうかな〜と思っていたのが、体験的には割と同じくらいに変わったので、こっちも放置せず更新しようと思います。「個人ブログをちゃんと更新する」という今年の抱負も決まってよかったです。今年もあと1ヶ月少しで終わりますが。また、記事の公開をボタンを押下するだけで行える(ように実装を改良した)ため、気軽にポストを更新できるようになり、これもモチベ向上に貢献しています。ただ Notion APIが色々足らなかったり仕様があやふやだったりで、バッドノウハウまみれになってしまい、まだまだ整えがいがある印象です。APIとそのエコシステムに関してはContentfulのほうが成熟していると思います。Contentful内の必要なデータを書き出す移行には 3rd-party のツールを使う方法もありますが、Contentful と Notion の API の素振りも兼ねて手作業で行いました。はじめにブログのコンテンツモデルを Contentful から書き出します。Docs に方法が記載されているので、これを参考に書き出しました。前準備として contentful-cli が必要なので適宜インストールします。インストールできた/していたら contentful login でログインして、contentful space use でスペースを指定します。あとは Docs に倣って contentful space export [...opts] を実行します。今回はブログの記事と画像のデータだけ欲しかったので、以下のオプションを指定しました。contentful space export --content-only --download-assets --use-verbose-renderer --export-dir ~/Downloads/contentful完了すると JSON 形式の Content データ(と画像)が書き出されます。てっきり各 Content ごとに1つのファイルで書き出されると思っていたら、以下のように 1つの JSON ファイルに全部の Content が詰め込まれてて驚きました。{ // "entries": [ { "metadata": { "tags": [ { "sys": { "type": "Link", "linkType": "Tag", "id": "others" } } ] }, "sys": { "space": { "sys": { "type": "Link", "linkType": "Space", "id": "fwqvgdqoxfc7" } }, "id": "5CB95Nkl2lPZIsZPAZC2II", "type": "Entry", "createdAt": "2021-03-24T13:37:40.223Z", "updatedAt": "2021-05-16T03:05:46.685Z" // }, "fields": { "title": { "ja-JP": "ブログを始めた" }, "body": { "ja-JP": "ブログを始めました。\n\n## きっかけ\n\n今までは..." }, "slug": { "ja-JP": "first-post" } } }, { // }, { // } ] }とりあえずこの JSON を記事ごとに割ります。import path from 'node:path'; import fsPromises from 'node:fs/promises'; import YAML from 'yaml'; import { unified } from 'unified'; import remarkParse from 'remark-parse'; import { fileURLToPath } from 'node:url'; import { visit } from 'unist-util-visit'; import contentsJson from './contents.json' assert { type: 'json' }; const LANG = 'ja-JP'; // @see https://stackoverflow.com/a/50052194 const __dirname = path.dirname(fileURLToPath(import.meta.url)); (async () => { const { entries } = contentsJson; for (const entry of entries) { const { metadata, sys, fields } = entry; const slug = fields.slug[LANG]; const updated = sys.updatedAt; const created = sys.createdAt; const tags = metadata.tags.map((tag) => tag.sys.id); const title = fields.title[LANG]; const head = { title, slug, tags, updated, created, }; const body = fields.body[LANG]; const destDirPath = path.resolve(__dirname, exports/${slug}); try { await fsPromises.access(destDirPath); } catch (_) { await fsPromises.mkdir(path.resolve(destDirPath, 'media'), { recursive: true, }); } const ast = unified().use(remarkParse).parse(body); visit(ast, 'image', async (node) => { const normalizedUrl = node.url.replace(/^\/\//, ''); const pathToImage = path.resolve(__dirname, normalizedUrl); const name = path.parse(pathToImage).base; const meta = { name, url: node.url, alt: node.alt, }; const metaPath = path.resolve(destDirPath, media/meta-${name}.json); await fsPromises.writeFile(metaPath, JSON.stringify(meta, null, ' ')); const imagePath = path.resolve(destDirPath, media/${name}); await fsPromises.copyFile(pathToImage, imagePath); }); const content = `--- ${YAML.stringify(head)}--- ${body}`; const filePath = path.resolve(path.join(destDirPath, content.md)); await fsPromises.writeFile(filePath, content); } })();出力は以下のような Markdown ファイルになります。--- title: タオルを煮る slug: boiling-towel tags: - personal_research updated: 2022-08-27T13:50:17.711Z created: 2022-08-27T04:45:58.789Z --- タオルを煮ました...YAML 形式の部分は後で Notion のプロパティに設定します。Markdown の書き出しと併せて、記事に含まれる画像も出力ファイルと同階層の media ディレクトリに移動しました。exports/ └── first-post/ ├── content.md └── media/ ├── first-post.jpg └── meta-first-post.jpg.json # 画像のaltなどのデータNotionにデータベースを作るNotion のデータベース機能で、以下のように記事を管理します。適宜必要なプロパティを作成することで、Contentful における Content Model を再現しました。記事の内容にあまり制約を加えないように、テンプレートには必要最低限の ToC ぐらいしかつけていません。あとはこのテンプレートからページを作成して、Contentful から取ってきた Markdown を import しよう!となるのですが…テンプレートから生成したページには Markdown を import できません。さらに、空のページに import したとしてもデータベースのデータ(ページ)として表示されず、サブページの扱いになります。ぐぬぬ…という感じです。(記事書き始めたのが今年の年明けとかなので、ここは改善されているかもしれないです)今回サブページは作りたくないので、とりあえず Markdown をページへコピペしました。プロパティは残念ながらコピペで入力できないため、手入力します。記事内の画像も手作業でページに貼り付けます。ここらへんはAPIに頼っていい場面ですが、筋肉で解決してしまいました。フロントエンドの更新・修正しばらく手つかずのコードだったので、依存の更新から行っていきました。Nextをv11からv13にあげ、ほかも可能な限り更新しました。なお、このブログでは App Router は使用していません。従来のSGで運用する上でそこまで不便はないのと、RSCの刺さりどころがイマイチ見当たらないので見送りました。ここらへんはまた機会があれば記事や登壇で小出しにするかもしれないです。あとはTailwindCSSを入れてUIをひたすら書き換えました。特筆すべき面白い点はないですが、詳細は気になる方は以下のcommitを見てください。https://github.com/shuta13/appearance-none/commit/2919bba198ab8f7fc324cd18f749c851b1a8ee5bhttps://github.com/shuta13/appearance-none/commit/2dfcba41542f6bafd39c8a6ac9743243bb9a2f47notion-sdk-jsで記事を取得更新の次は実装を修正し、記事の取得を行いました。ContentfulのSDKをuninstallし、Notionのもの(notion-sdk-js)を用いるようにしました。簡単にこのSDKの使い方を説明すると、以下のようにクライアントを生成し、これに対してメソッドチェーンでページやデータベースを取得するような書き方をします。import { Client } from '@notionhq/client'; const client = new Client({ auth: accessToken, }); // ページのタグなどプロパティが入っている const pageProps = await client.pages.retrieve({ /** }).properties; // ページ内のブロックなどが入っている const pageContents = await client.blocks.children.list({ /** });詳しくは https://developers.notion.com/docs を見てください。fetcher等の実装は必要なく、かなり手軽にNotionからデータ取得できます。今回はClean Architecture風味の構成に寄せ、Notionのデータベースから記事の内容をビルド時に引いてこれるようにしました。以下の順でデータを取得します。repository → model(presenter) → usecase → UIrepositoryでSDKを呼び出し、ページのプロパティやコンテンツを取得します。modelではUIが求める形にSDKのAPI Responseを整形します。usecaseでここまでのデータを取得し、getStaticProps内で組み立ててPagePropsに突っ込みます。これで UI に表示が可能になります。言葉で説明しても分かりづらいので、以上の実装を掲示します。例えば記事1件取得の実装だと以下のようになります。repositoryimport ArticlesModel from '~/models/articles'; interface ArticleRepository { getArticle(params: { id: string }): Promise<Articles[number]>; } const article: ArticleRepository = { async getArticle({ id }) { const pageProps = await client.pages.retrieve({ page_id: id, }).properties; const pageContents = await client.blocks.children.list({ page_id: id, }); /** return { meta: { id }, head: { title: pageProps.Name.title[0].plain_text, }, body: ArticlesModel.transform(pageContents.results), }; }, };modelimport { Articles } from '~/repositories/article'; // type(interface)とnamespaceのmergingを利用 type ArticlesModel = Readonly<Articles[number]>; namespace ArticlesModel { export function transform(result: Articles[number]): ArticlesModel { return { meta: result.meta, head: result.head, body: normalizeList(result.body), }; } export function normalizeList( body: (Articlesnumber[number] | null)[] ): ArticlesModel['body'] { /** UIでほしい形にbodyを整形 */ } }usecaseimport repo from '~/repositories/article'; export function getArticle() { return { async invoke(params: { id: string }) { const result = await repo.getArticle(params); return result; }, }; }getStaticProps → UIexport const getStaticProps = async (context: GetServerSidePropsContext) => { const article = await getArticle().invoke({ id: context.params?.slug || '', }); return { props: { article } }; }; const Slug: NextPageWithLayout< InferGetStaticPropsType<typeof getStaticProps> > = (props) => { const { article } = props; return ( <article> <div dangerouslySetInnerHTML={{ __html: article.body.map((article) => article.htmlStr).join(''), }} /> </article> ); };実装の見通しが良くなる反面コード量は増える印象です。Notion側にデータ送信する必要が出た場合に真価を発揮するかもしれません(CMS側更新することなんかあるのか?という感じですが)。個人的にNext.jsはサーバーサイドフレームワークだと思ってるので、Stateをサーバーサイドに寄せてしまえばClean Architectureと相性良いような気がしました。Notion API Response → React ElementNotion APIへの理解を深めるため、Notion API ResponseをReact Elementに変換するRendererを実装しました。正直根性実装です。Notion Blockの種類を判別し、それに応じたtagNameでElementを構成しています。例えば見出し1(h1)は以下のようにElementを構成します。import { Heading1BlockObjectResponse } from '@notionhq/client/build/src/api-endpoints'; export function isHeading1( block: BlockObjectResponse ): block is Heading1BlockObjectResponse { return block.type === 'heading_1'; } export type SwitcherReturn = { tagName: keyof JSX.IntrinsicElements; }; export function switcher(block: BlockObjectResponse): SwitcherReturn { if (isHeading1(block)) { return { tagName: 'h1', }; } } export function renderer(block: BlockObjectResponse) { const { tagName } = switcher(block); const Element = tagName; if (isHeading1(block)) { const content = block.heading_1.rich_text; return ( <Element id={content}> <a href={#${content}} dangerouslySetInnerHTML={{ __html: content, }} /> </Element> ); } }type guardでBlockに型をつけることで switcher や renderer の中で参照した際に、型推論が効くようにしています。同様に必要なtagNameに応じた処理を追加し、ulやcodeなど、よく使うElementは表示できるようにしました。詳しくは実装見てもらうほうが早いと思います。https://github.com/shuta13/appearance-none/blob/main/packages/utils/notion-block-renderer.tsx画像の保存有名な話かもしれないですが、Notionの画像は署名付きURLになっています。URLの有効期限は 1 時間なので、画像を表示したくば 1 時間毎にビルドする必要があります。普通に嫌です。https://developers.notion.com/reference/file-object#notion-hosted-files:~:text=The URL is valid for one hour. If the link expires%2C then you can send an API request to get an updated URL困ったのでインターネットを眺めていると、自前のStorageに持っていったり、base64にしてみたり、ダウンロードして全部ローカルで腹持ちにしたりする対応を取っている人がちらほら見られました。https://www.google.com/search?q=notion+画像+署名付きurl&sca_esv=579945731&ei=83JJZbfOGsGA1e8P85SkyAE&ved=0ahUKEwj3k4zyv7CCAxVBQPUHHXMKCRkQ4dUDCBA&uact=5&oq=notion+画像+署名付きurl&gs_lp=Egxnd3Mtd2l6LXNlcnAiHW5vdGlvbiDnlLvlg48g572y5ZCN5LuY44GNdXJsMgUQABiiBDIFEAAYogRIpAxQrAdY6QpwAXgBkAEAmAFsoAGDA6oBAzMuMbgBA8gBAPgBAcICChAAGEcY1gQYsAPiAwQYACBBiAYBkAYK&sclient=gws-wiz-serp#ip=1今回は1番最後の方法で、Actionsでビルド中に画像を /public に保存することにしました。こちらの方の実装を参考に、repositoryで取得した記事のBlockを総なめして、画像であればfsでフォルダに書き出しています。if (item.type === 'image' && item.image.type === 'file') { if (!isImageExist(item.id)) { const blob = await fetch(item.image.file.url).then((res) => res.blob()); const binary = (await blob.arrayBuffer()) as ArrayBuffer; const buffer = Buffer.from(binary); fs.writeFileSync(path.join(imageBasePath, /${item.id}.png), buffer); } item.image.file.url = ${imagePathName}/${item.id}.png; pageContents.results[pageContents.results.indexOf(item)] = item; }ホストに用いているサービスの容量の上限には気を配らないといけませんが、一旦これで解決しました。ちなみにこのブログはCloudflare Pagesでホストしてるので、画像等はなるべくR2やD1に持って行ったほうが良さそうです(確かPagesは1プロジェクト25MBが上限だった気がします)。GitHub Actionsに発射ボタンを生やすこんな感じで Run Workflow ボタンを押すとブログのデプロイが走ります。Cloudflare PagesはGitHub Reposと紐づけができますが、pushごとにビルド回るのが煩わしかったので、Actionsでwrangler使って任意のタイミングでビルドするようにしました。ちなみに以下の記述だけで Run Workflow ボタンを生やせます。on: [workflow_dispatch]詳しくは https://github.com/shuta13/appearance-none/tree/main/.github/workflows にあります。まとめ筋肉で解決したりゴリゴリ実装したり、ちょっと大変な引っ越し作業になってしまいました。Notion APIに触れた感想として、今のところNotionはUXに振り切ったソフトウェア感があります。ふと、昔のFigmaは開発者にあまり優しくなかったが、だんだん改善されていったように思ったので、時間が経てば同様にDX方面も充実するかもしれません。今流行っている点以外は特に共通点ないのと、そもそもNotionがHeadlessな仕組みをあまり作りたくなさそうではありますが。あとnext devするとかなり重いです。NotionのAPI Response整形する部分で、デカいデータ取り回してるあたりが原因だと思うので、もうちょっとテコ入れしたいなと思います。NotionをHeadless CMSとして使うことを勧める気持ちはないですが、何かの参考になれば幸いです。次ブログに関して書くときは、notion-sdk-jsを読んでみる回にでもしようかなと思ってます。ご精読ありがとうございました。出典・参考カバー画像
  • はちみつチーズ豆腐

    2022/09/05

    インターネットでバズっている様子を見かけたのやってみます。バズるならおいしくあれ。用意するものは以下です。豆腐スライスチーズはちみつ塩こしょう豆腐は水気を切っておきます。ここでムキになってすべての水分を抜こうとするとボロボロの植物性タンパク質が出来上がる。キッチンペーパーで包んで冷蔵庫で少し寝かしておくぐらいにしました。豆腐からある程度水気がなくなったらお好きな器に移してスライスチーズを乗せてレンチンしましょう。チーズが溶けるぐらいに加熱したらOKです(500Wで1分半ぐらいいきました)。この時点で味見をしたんですが豆腐とチーズの味がします。まとまりがなく、お互いの良さを引き出し合うようなドラマもありません。不安を感じながらもここに残りのはちみつと塩こしょうを投入します。元ツイートには「パラッと」かけましょうとあったのでおそらく少量程度で十分です。例によって写真はありませんので適当に調べて想像してみてください。感想豆腐とチーズとはちみつの味がしました。あたたかい豆腐の上で今にも固まりつつあるチーズがどこかへ飛んでいき、遅れてはちみつの風味が広がります。ダイエットをするために残された手段はもう断食しかないのでしょうか。ミネソタ飢餓実験 | Wikipediaありがとうございました。
  • タオルを煮る

    2022/08/27

    タオルを煮ました。https://twitter.com/did0es/status/1563380617089531904ついに社会に耐えきれず狂ってしまったとか、鼻セレブを食べる話を思い出してタオル版をやってみたとかでもないです。しっかり洗って乾かしたタオルを顔にボフってしたときに、たまにめちゃくちゃ汗臭い人が目の前にいる感覚になるじゃないですか。よくこれで絶望しています。世間一般ではこの臭いを生乾き臭と呼ぶそうです。しっかり乾かしたのに「生乾き」って言われる意味がわからない。原因究明をしたくなるのは筆者の性です。手元の検索エンジンに生乾き臭と打ち込んでEnterを押してみると求めてもいないのに生乾き臭の消し方を提案してくれます。いずれ消すことにはなるが、無慈悲に消す前にこいつのことを知りたい。さまよっていると花王がこの臭いの原因について研究を行っていた様子を見つけました。2011年に日本微生物生態学会が公開しています。https://dl.ndl.go.jp/info:ndljp/pid/10881756これによると Moraxella osloensis という菌が作り出す4-メチル-3-ヘキセン酸という物質があの臭いの正体らしいです。https://jglobal.jst.go.jp/detail?JGLOBAL_ID=201007032657339334あいにく生物学にはさっぱりなのですが、物質を分解するよりもおそらくこの菌が物質を排出する前に仕留めることが出来ればこの勝負は楽に勝てるでしょう。相手は細菌ということで、増殖を抑制あるいは死滅するような温度が存在するはずなのでこれを調べていきます。ありました。https://www.phar.agu.ac.jp/lab/microbiol/Research.htm#:~:text=洗濯物生乾き臭原因菌Moraxella osloensisの制御に関する研究愛知学院大学 薬学部 医療薬学科が「洗濯物生乾き臭原因菌Moraxella osloensisの制御に関する研究」として花王と共同で研究を行っていたようです。この記事によると「約60℃の温度に曝すことにより、水に懸濁状態の細菌では100%、布に付着した状態の細菌でも99%以上、殺菌できる」そうです。役者は揃ったのでやっていきます。用意するものは以下です。サーモス5点セットに含まれる1番でかい鍋残念な臭いのするタオル(x3)熱湯ちなみに60℃の熱湯につけて秒で死滅するか弱い菌ではないので、だいたい12分程度は継続して加熱する必要があるそうです。めんどくせぇ。60℃の温度を維持するのはかなり厳しいので鍋を火にかけっぱにして、沸騰しない程度に差し水して行います。また予想以上にタオルがボリューミーだったため、5点セットに付いてきた蓋で適当に落し蓋的なことをして個性を出してみました。この試みの最中、親指に軽度なやけどを負いました。なお筆者がこのとき二日酔いであることや、洗濯物をたたみかつコーヒーを淹れるという多忙な状態であるため、冒頭のツイート以外の写真はありません。https://twitter.com/did0es/status/1563383722103414786ぼんやりしていると30分経過していました。慌てて取り出して水気を絞ってそのまま洗濯機に放り込みます。結果臭いが吹っ飛びました。吹っ飛ばなかったらこの記事を書いていません。かがくのちからってすげー!
  • 卒論を支えた技術

    2022/01/17

    とりあえず学部の卒論が書けたので、ゴール前に寝そべりながらこういう文章を書いてる。ここ半年ぐらい卒論という存在の生活に占める割合が大きすぎて、個人として技術に関して得た知見のアウトプットがあまり出来なかったので卒論で構築したWebアプリをネタにちまちま書く。さらっと研究紹介ある用途に向けた音声のデータセット構築を行っていた。データセットの構築は「構築して終わり」じゃなくて、構築したデータセットの性能や特性の評価とかも行うが、この過程に対してそこまで興味が沸かなかったので今回は書かない。手法調べてGoogle Colabでscikit-learn使ってバババッてしたらモデルの学習完了だったので、特に収穫と思しきものがなかったし楽しくもなかった。全部完了して「よし卒論書くぞ」と始めたところ、結局データセットの仕様やら評価手法やらを中心に書くことになったので、全然データ収集の話を書けなくて萎えていた。論文だから仕方ないがせっかくなのでブログに吐き出して供養する。研究を支えた技術アプリの背景はじめにアプリの背景について触れる。データの収集にはご時世ということもあって人の声を対面で録音することが憚られたので、録音兼ラベル付け用Webアプリをフルスクラッチで実装(フロントエンド・バックエンドを実装)し、クラウドソーシングで募った被験者にURLを渡して行った。合計で700 800人ぐらいがこのアプリを使った。構想からリリースまでの期間は大体3ヶ月ぐらい。割と期間に余裕があるように思えるが、もちろん研究以外のこともやってたので、実質1ヶ月ぐらいの工数で進めてたと思う。去年の夏頃に卒論テーマが決まってから全てのデータ収集が完了したのが12月ぐらいなので、メンテ・その他対応(教授からの唐突な要求に答えるための対応や、ちょっとした障害の対応など)も含めると期間として半年はアプリのソースコードをいじってた。ちなみに見せられないよって感じの部分があったり、アプリのURLからクラウドソーシング上のタスクを推測されて変なことされると困るので、見せられるブツは特に無い。その代わり技術的な取り組みをなるべく細かく書く。アプリの構成アプリのフロントエンドにNext.js、バックエンドにPHPを使った。技術選定の根拠を話すと、研究室で借りているさくらのレンタルサーバーでアプリのホスティングを行うことになったのでこうなった。ホスティング先決定前はNode.jsでバックエンドを実装する気満々だったが、このレンサバ上では動かない(参考: https://rs.sakura.ad.jp/function/cgi/#::text=独自にプログラミングしたCGI,言語が使用できます。)ので次に慣れているPHPを使った(ちなみにさくらのレンサバでNode.jsが動いたとしても、daemonを実装すると強制停止させられる可能性があるのでどのみち厳しかった気がする: https://help.sakura.ad.jp/206206041/?_ga=2.103246144.1768828013.1642372001-472689077.1617430242)。フロントエンドはNext.jsのSGで書き出した静的ファイルをレンサバの公開ディレクトリに設置するようにした。PHPのコード中にHTMLを絶対に埋め込みたくないというか、あまりPHPを書くモチベがなかったのでNext.jsで解決出来ることは全部これでやった。以下リポジトリの構成。monorepo風味のプロジェクトになっている。Yarn Workspaceを使って、scriptsには収まらないような適当なCLIツールも一緒に実装出来るようにした。. ├── README.md ├── babel.config.js ├── data/ ├── jest.config.js ├── package.json ├── scripts │ ├── add-fake-choice.js │ ├── check-listen-num.js │ ├── deploy-client.js │ ├── deploy-server.js │ ├── filter-listen-data-120.js │ ├── format-user-meta-data.js │ ├── generate-dataset.js │ ├── generate-text.js │ ├── group-recorded-data.js │ ├── json2csv.js │ ├── modify-text-ruby.js │ ├── shared/ │ ├── transform-record-result.js │ └── validate-listen-result.js ├── setupJest.ts ├── tsconfig.base.json ├── workspaces │ ├── client/ │ ├── server/ │ └── types-bridge/ └── yarn.lock workspaces/types-bridge については この記事 で書いたので良ければ見てほしい。scripts がてんこ盛りになっているが、アプリ用のデータ準備や収集したデータをデータセットに纏め上げる処理を全てここに集約させた。scripts で特に見てもらいたいのがgenerate-text.jsの一部で、以下の処理でJNASの新聞記事文のテキストルビのTeXファイルからASTを取り出し、読み上げ文用に加工している。ASTを触る機会が増えていたのでこういう実装をさらっと出来て良かった(再帰関数の中はあまり綺麗ではないが)。const latexAstParser = require('latex-ast-parser'); // let count = 0; /** * @type {string[]} */ let buffer = []; /** * @param {{ type: string, content: any }[]} node * @returns {void} */ const latexNodeVisitor = (node) => { node.forEach((n) => { if (Array.isArray(n.content) && n.type === 'environment') { latexNodeVisitor(n.content); } else { if (new RegExp(/^\\d*$/).test(n.content) && n.type === 'string') { if (count > 1) buffer.push(popSidParFour(n.content)); count++; } if ( n.type === 'group' && n.content[0].type !== 'comment' && n.content[0].content !== 'jarticle' ) { buffer.push(n.content[0].content); } } }); }; /** * * @param {(arg: { type: string, content: any }[]) => void} visitor * @returns */ const latexNodeTraverser = (visitor) => { return (ast) => { if (ast.type === 'root') { return visitor(ast.content); } else { throw new Error("Given AST's type is not root."); } }; }; // (async () => { // const textRubyTexData = await getTextRubyData( 'LONG', TEXT_DATA_CONFIGS.DEBUG_FILE_NUMBER ); const traverse = latexNodeTraverser(latexNodeVisitor); traverse(latexAstParser.parse(textRubyTexData)); // })(); レンサバへのデプロイはFTPを使った。ftp-deploy というNPMパッケージがあるので、これを使って以下のような簡単なデプロイスクリプト(scripts/deploy-client.js)を実装して、Actionsのワークフロー上でフロントエンドのビルド後に実行した。ちなみに dotenv を入れているのは、Actionsが無料枠の天井に到達したときローカルからデプロイするためだったが全然そんなことなかった。サーバーサイドも同様に、書いたPHPのコードをディレクトリごとFTPでレンサバに突っ込んでいる。const dotenv = require('dotenv'); dotenv.config(); const FTPDeploy = require('ftp-deploy'); const ftpDeploy = new FTPDeploy(); const path = require('path'); const config = { user: process.env.FTP_USERNAME, password: process.env.FTP_PASSWORD, host: process.env.FTP_HOST, localRoot: path.resolve(__dirname, '../workspaces/client/out'), remoteRoot: process.env.FTP_REMOTE_ROOT, include: ['*'], }; ftpDeploy .deploy(config) .then((res) => console.log('finished: ', res)) .catch((err) => console.log(err)); あとは収集したデータをバックエンドで永続化する際、RDBに出し入れするのが面倒だった(レンサバ上で運用するDBのセキュリティ面の知見を持っていなかった)ので基本的にJSONファイル、音声はWAVEファイルにしてレンサバに保存した。アプリの機能としては大きく分けて2つで「音声の録音」「音声の評価(ラベル付け)」がある。次はそれぞれについて書く。音声の録音収集したい音声が以下の形式だった。雑に表現すると"そこそこ質の良い研究に使うにあたって一般的な形式の音声"を収集しようとした。サンプリング周波数: 48kHzチャンネル: モノラル量子化ビット数: 16bitファイル形式: WAVE音声の録音にはMediaStreamRecording APIという、WebRTC関連のWeb APIを利用した。ここで1つ問題になるのが、被験者は各々使いたいブラウザを使用して音声を録音することで、あるある〜って感じだがやはり悩まされた。とりあえずpolyfill入れておくかと https://github.com/ai/audio-recorder-polyfill を入れたが、後々になって別にIEは注意書きで使わないように言ったので必要なかった気もする。また、Firefoxでは NotSupportedError: AudioContext.createMediaStreamSource: Connecting AudioNodes from AudioContexts with different sample-rate is currently not supported. の通り、サンプリング周波数が44.1kHzから変えられなかったので、仕方なしにアプリの注意書きにFirefoxを使わないように書いた。Firefoxの使用を禁止したのは今までで初めてだった。なお、このエラー を調べるとこういうGitHub issue上でのやり取りが出てくる。Firefox側で対応されたらしいが、自分のmacOS Catalina環境や学内のCentOS 7環境ではバグっていたので直ってない気がする。両方ともOSが古いのも原因としてありえそう。とりあえずこれらのことは置いておいてpolyfillを使いつつuseRecorderフックを実装し、録音を行う実装を含むReactコンポーネント内で利用するようにした。このフックはhttps://codesandbox.io/s/81zkxw8qnlを参考にした。/** * <https://codesandbox.io/s/81zkxw8qnl> * <https://developers.google.com/web/fundamentals/media/recording-audio> * NOTE: ↑ No support for wav format. */ import { useEffect, useState } from 'react'; import AudioRecorder from 'audio-recorder-polyfill'; import type { TextData } from '../components/RecordPage'; const requestRecorder = async () => { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const recorder = new AudioRecorder(stream, { sampleRate: 48000 }); return recorder; }; export const useRecorder = () => { const [audioData, setAudioData] = useState< { id: number; text: string; ruby: string; url: string; formData: FormData; }[] >([]); const [isRecording, setIsRecording] = useState(false); const [currentId, setCurrentId] = useState(0); const [currentText, setCurrentText] = useState(''); const [currentTextRuby, setCurrentTextRuby] = useState(''); const [recorder, setRecorder] = useState<typeof AudioRecorder | null>(null); const startRecording = async (data: TextData[number]) => { setCurrentId(data.id); setCurrentText(data.sentence); setCurrentTextRuby(data.ruby); setIsRecording(true); }; const stopRecording = () => { setIsRecording(false); }; const onSuccess = async (stream: typeof AudioRecorder) => { setRecorder(stream); }; const onError = (reason: any) => { console.error(reason); alert('エラーが発生しました。ページを再読み込みしてください。'); setCurrentId(0); setCurrentText(''); setIsRecording(false); setRecorder(null); }; const handleOnDataAvailable = async (event: BlobEvent) => { /** * NOTE: 以下の WAV データに変換 */ if (event.data.size > 0) { // プレビュー用の URL 作成 const url = URL.createObjectURL(event.data); // サーバーへ送信用の FormData 作成 const formData = new FormData(); formData.append('audio_data', event.data, ${currentId}); setAudioData((prevState) => [ ...prevState, { id: currentId, text: currentText, ruby: currentTextRuby, url, formData, }, ]); setCurrentId(0); setCurrentText(''); } }; const removeAudioData = (id: number) => { setAudioData((prevState) => prevState.filter((state) => state.id !== id)); }; useEffect(() => { if (recorder === null) { /** * 録音ボタンが押された状態であれば初回 getUserMedia でマイクへのアクセスの許可と AudioRecorder のインスタンス作成 */ if (isRecording) { requestRecorder().then(onSuccess, onError); } return; } if (isRecording) { recorder?.start(); } else { recorder?.stop(); } recorder.addEventListener('dataavailable', handleOnDataAvailable); return () => { recorder.removeEventListener('dataavailable', handleOnDataAvailable); }; }, [recorder, isRecording]); return { audioData, isRecording, startRecording, stopRecording, currentText, removeAudioData, }; }; ちなみにコードの冒頭にごちゃごちゃ書いているが、https://developers.google.com/web/fundamentals/media/recording-audio の例で示されている方法では WAVE ファイルとして音声を録音出来ない(.wavにはなるがffmpegとかで確認すると壊れているし、そもそも再生出来ない、wave surferで表示すら出来ない)。直そう直そうと思って全然やる時間が無いので、コントリビュートチャンスに飢えている誰かいればやってほしい。あとはフックを使っていい感じに録音UIを組めばおわり。Firefoxの件以外はそこまで苦労しなかった。音声の評価被験者による主観評価によって音声へのラベル付けを行った。評価のUIは各項目1つだけ選択するアンケート形式で実装したかったので、react-hook-formを利用した。実装したReactコンポーネントはこんな感じ。import { SubmitHandler, useForm, UseFormRegisterReturn } from 'react-hook-form'; import { LISTEN_CONTENTS } from '../../configs'; import { EuterpeButton } from '../EuterpeButton'; import { ColorParette } from '../Instruction'; import styles from './EuterpeRadioForm.module.scss'; export type SelectionsInput = { [K in keyof typeof LISTEN_CONTENTS]: keyof typeof LISTEN_CONTENTSK; }; type CheckboxInputProps = { value: keyof typeof LISTEN_CONTENTSkeyof typeof LISTEN_CONTENTS; formRegister: UseFormRegisterReturn; }; type FieldProps = { contents: typeof LISTEN_CONTENTS[keyof typeof LISTEN_CONTENTS]; formRegister: UseFormRegisterReturn; }; type Props = { onSubmit: SubmitHandler<SelectionsInput>; inputDisabled: boolean; }; const RadioInput: React.FC<CheckboxInputProps> = ({ value, formRegister }) => { return ( <input type="radio" value={value} {...formRegister} className={styles.field_radio} /> ); }; const Field: React.FC<FieldProps> = ({ contents, formRegister }) => { return ( <div className={styles.field_wrapper}> <span className={styles.field_question}>{contents.question}</span> {Object.keys(contents.selections).map((key) => ( <label key={key} className={styles.field_container}> <RadioInput value={key as keyof typeof contents['selections']} formRegister={formRegister} /> <span> {contents.selections[key as keyof typeof contents['selections']]} </span> </label> ))} </div> ); }; export const EuterpeRadioForm: React.FC<Props> = ({ onSubmit, inputDisabled, }) => { const { register, handleSubmit } = useForm<SelectionsInput>(); return ( <form onSubmit={handleSubmit(onSubmit)}> <Field contents={LISTEN_CONTENTS.consistency} formRegister={register('consistency', { required: true })} /> <Field contents={LISTEN_CONTENTS.recordingConditions} formRegister={register('recordingConditions', { required: true })} /> <Field contents={LISTEN_CONTENTS.easeOfListening} formRegister={register('easeOfListening', { required: true })} /> <EuterpeButton type="submit" text="⏩ 次へ" bgColor={ inputDisabled ? ColorParette.DISABLED : ColorParette.ACCENT_GREEN } disabled={inputDisabled} /> </form> ); }; バックエンド正直あまり書くことがない。本業ではないという言い訳をしながら、歴戦のPHPerが見ると卒倒しそうなコードを書いた。レンサバ上でcomposer走らせるとかが出来なかったので、とりあえず FormData で送信されてきた音声と評価結果などの各種テキストデータを書き出す処理をそのままのPHPで書いた。local では composer を使えるので、PHPStanの静的解析で型チェックをしたり、PHP-CS-Fixerでコードのフォーマットぐらいはやった。あと意識したのがセキュリティで、Laravelとかのフレームワークに乗っからなかったので全部調べて自分でどうにかした。Qiitaで@tadsanさんや@rana_kualuさんあたりの記事を見たらマシなPHPが書けた。例えばPOSTされた評価ラベルデータを書き出すとかだとこういう感じでPHPを書いた。<?php declare(strict_types=1); error_reporting(E_ALL); include dirname(FILE) . "/../shared/mkdir.php"; include dirname(FILE) . "/../shared/http_variables.php"; include dirname(FILE) . "/../shared/console.php"; include dirname(FILE) . "/get_data.php"; header('Content-Type: application/json; charset=utf-8'); header('X-Content-Type-Options: nosniff'); $http_variables = new HttpVariables(); $mkdir = new Mkdir($http_variables); $mkdir->set(); $get_data = new GetData(); (function () use ($mkdir, $http_variables, $get_data) { $console = new Console(); $files = $http_variables->files('result_data'); $target_dirname = $http_variables->get('target_dirname'); $target_data_path = realpath('../record/data/' . $target_dirname); $data_map_json_path = $target_data_path . '/data_map.json'; $console->log($data_map_json_path); $data_map = $get_data->get_target_data_map($data_map_json_path, $target_dirname); $data_map['listen_num'] += 1; file_put_contents($data_map_json_path, json_encode($data_map, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); $input = $files['tmp_name']; $output = $files['name'] . ".json"; if (move_uploaded_file($input, "./" . $mkdir->full_data_dir_name . "/" . $output)) { echo json_encode(['message' => 'Created listen result data.']); } else { http_response_code(405); } })(); JSerは即時関数を使いがち。PHPは慣れていると言いつつも飛び道具程度にしか書いてなかったので、マジでこの部分危ないよ!とかのマサカリ大歓迎です。歴戦のPHPerの元で PHP だけ書く修行をしたいが、PHPで飯を食うつもりが今の所ないのでこの有様です。振り返りNext.jsをVercel以外でホスティングするのはやはり面倒だった。若干つまづいた所もあったが、適当なCDNに乗せるみたいなイメージでできた。反省としては、同時接続の件数がかなりあったのに対応出来ていなかったところがある。収集できたデータを確認していると同時にリクエストが飛んできて書き込みに失敗し、破損しているデータがちらほらあった(これらはクラウドソーシングで再発注した)ので、やっぱりDB使おうねという気持ちになった。DBまわりのセキュリティ勉強する。卒論執筆を支えた技術ついでに卒論執筆環境についてもちょっとだけ工夫したので書く。卒論はTeXで書いた。周りはCloud LaTeXなどのオンラインのサービスを使っていたが、研究室で色々あってWiFiがダウンしたりしたときに面倒なのでローカルに環境を作った。ただ、ローカルだとデータが吹っ飛ぶと絶望する羽目になるので、GitHub上にプライベートリポジトリを作って適当な進捗が出る度にpushするようにした。環境構築mac勢なので学部2回生の頃ぐらいにとりあえずTexShopをインストールしてた。ちなみにこれに付属してるエディタは卒論では使ってない。おわり。エディタで論文のプレビューを見れるようにするこっちに注力してよかった。エディタには慣れているVSCodeを使っていた。VSCodeにはLaTeXにむけた拡張機能でLaTeX-Workshopというものがある。これを導入すると、VSCode上でTeXのビルド・プレビューを行いながら執筆出来る上にコード補完とかもある全部のせセットなのでかなり捗った。添削にむけた差分強調卒論を書くにあたって指導教員から添削をもらってマシな論文に近づけていくと思うが、その際に何度も繰り返し添削を受けることになると思う。そこで、前回修正した点と今回修正した点が一目見て分かるようにすると教員側に対して優しいのでやった(というかこれをやらないと添削してもらえなかった)。latexdiffを用いると、訂正前と後の差分をまとめたTeXファイルを吐き出させることが出来る。また、gitを使ってバージョン管理している場合はlatexdiff-vcのgitオプションを使用すると、コミットごとに差分をまとめることが出来る。これで解決!といきたいところだが、僕の手元のLaTeX環境ではlatexdiff(latexdiff-vc)を利用して吐き出したファイルのfigureが表示されない(多分\\usepackage[dvipdfmx]{color} 加えたら表示されるが、めちゃくちゃ焦ってて気づいてなかった)とか、文章に謎の改行が入るとか、差分を出すには若干手間だったので断念した。代わりにhomebrewでインストール出来るdiff-pdfでPDFの差分を見つつ、一つ一つ手作業で変更箇所の色を変えた。TeXで色を変更したい箇所は\\newcommand{\\red}[1]{\\color{red}#1\\color{black}}のようなマクロを使って\\red{変更箇所}のように書いた。他に卒論を支えたもの技術以外の話。睡眠・運動睡眠1番大事。徹夜はやめたほうがいい。20代は無敵!とか思ってたが意外と簡単に身体は壊れる。パソコンは雑に扱っても壊れないが、人は雑に扱うと壊れるって昔大学の先輩が言っていた。とりあえずなんか不調だなと思ったら寝るようにした。人とコミュニケーション取りたくない(Slackやメールの返信がめちゃくちゃしんどいとか)って思った時はかなり疲弊しているので寝ろ!みたいなのをどっかで見た気がする。これを実践していた。あとは運動すると眠くなりやすいのでおすすめ。合法的に暴れられるのでストレス発散にもなって心身に良い。余裕のあるときは駅前のエニタイムフィットネスに1日おきぐらいで通ってた。別にステマではないけど24時間開いてるのが助かった。人と話す・飯食いに行く1人で作業してて煮詰まったら人と他愛もないことを話すと良かった。話すために研究室に行って卒論書いてたり、友達とdiscordで作業したりしてた。人と飯食いに行くのも気分転換によくやってた。飯の際の酒は程々に。2日酔いになると詰む。(適度な)ゲーム適度ならかなり良い気分転換になった。友達と本気でApexのランクマッチやったり、最近配信するのを勧められて暇なときにTwitchで配信してみたりしてる。やるなら気軽にはじめられて1人でも他の人と一緒でも出来るゲームがおすすめ。のめり込むとこちらも飲酒と同様に詰む。(適度な)散財こっちも適度にやると気分転換になる。何回かミスってクレカ上限突破グレンラガンした。最近だと春服とかメンズコスメとか、春から始まる生活で便利そうなものとかを爆買いした。買い物しながら大学卒業後の生活を想像すると楽しい。卒論で煮詰まったら自転車操業にならないように気をつけつつ、好きなものに対しての散財はぜひ。おわりにとにかく卒論の修正と発表会頑張る。
  • 独り立ちの話

    2021/12/21

    独り立ちの話12月でオフィスが無くなると聞いて挨拶へ行ったとき、バイトしていた頃に先輩と休憩に出てきて、煙草吸いながら色々話し込んでいたところに1人で上がった。昔そうしていたみたいに、雨風で塗装が剥げかかったベンチに座ってぼーっとふかしてると、そういえばここで写真撮ったことなかったなと思った。最後の記念に、と適当にシャッターを切るとふつふつと名残惜しさが湧いた。株式会社タンバリンを12月末で退職する。フロントエンドも全く分からず、エンジニアについても右も左も分からない頃に僕を拾ってくれた。 大学受験に失敗し大学生活もうまく行かず、自暴自棄になってこの際もう大学辞めるか、みたいな気持ちでやってきた僕をあたたかく迎えてくれた。何も世間を知らないただ尖っていただけの自分に対して、Webエンジニアに限らずエンジニアとしてどう振る舞えば良いのかとか、チームってこういうものなんだよとか、今では当たり前・出来て当然と切り捨ててしまうようなことまで全部教えてくれた。過去にすがっていられない、すがるものも無いとめちゃくちゃに走っていたが、すがらなくとも頼っていい存在が出来た。Spotify みたいなポッドキャスト作りたい!って言っていたら社内アドベントカレンダーの内容にしていいよと、こころよくやらせてもらえた。 僕がインスタレーションやりたい!と言ったら、わざわざ担当していた社員が僕を担当に入れてくれて、それの Web 部分を作ったこともあった。 試用期間が終わって内定が出た時、僕はそれを辞退したがそれでもここに残っていいと席を用意してくれた。いつも顔を出すと調子はどう?と言ってくれる先輩が出来た。 周りに甘えっぱなしで、無茶やわがまましか言わない自分だったが、それでも付いてきてくれる後輩も出来た。元 Flasher としてインターネット黎明期を支えてきた人、面倒見の良い先輩や OSS 好きなエンジニア、今の僕の理想像の全てがここにあった。気づくともう大学生でいられるのも数ヶ月になった。 あれほど辞めたがっていた大学も残り少しになると、それどころではなく卒業まで死ぬ気で頑張るぞって気持ちで過ごしている。それと、僕は理想のエンジニアになれたのか、なれていないのかを自問することが増えた。 でも自分が憧れたのは、好きなことに向かって突っ走るエンジニアだった。突っ走った後、何が楽しかったのかを周りに喜々と話してくれる、そういう存在になれたらいいなと思う。3年間お世話になりました。I think this is the beginning of a beautiful friendship. ''Casablanca''
  • 近況報告 - OSS, 卒論, 他

    2021/11/15

    毎日日報を書いているフォロワーがいて、偉いな〜と思った。一生味のしないガム噛み続ける内容しか書けない気がしておれには無理です。近況っぽい話比較的元気です。卒論をナメていた結果卒論で順調に生活が狂わされ毎日逃げのApexみたいな生活を送りつつあったのを立て直した。大きな変化としてはOSSのOutside Memberになった(リポジトリのコラボレーターになった)のと、しばらくやっていなかったエンジニア業を再開した。他にジムに通い始めたので、眠気が早くきて強制的に生活が正しくなった。誕生日にほしいものリストからフォロワーにものをせびった結果プロテインと筋トレグッズばっかり送られてきてかなり良かった。敵に塩関係無しになんでも送ると良い。あと1人のときに酒を飲まなくなったので、遊生夢死野郎度が下がって良かった。まともな思考で余計なことかぼんやりとした不安に向き合っている。その分人と飲みに行ったときの粗相をする確率がかなり上がった。大学の同期と行った焼き肉でフランベしたり、サシで飲んだときに最悪の酔い方をして酒をぶっかけて次やったら泣くって言われたりしてる。はやく禁酒したい。禁煙は試みたが3回失敗してる。また値上がりで喫煙者への風当たりが凄いのでそのうち辞める。納税は最高。OSShttps://github.com/pmndrs/three-stdlib のコラボレーターになった。Poimandres の他のリポジトリに軽く PR 出したり、https://github.com/pmndrs/react-three-next の機能実装したりしていたが、結局 three-stdlib での JS → TS の貢献が大きかったようで、そのリポジトリを任せてもらえた。three-stdlib は Three.js の examples に入っているモジュールを外部に切り出したスタンドアロンとして使うようなライブラリになっている。Three.js には色々お世話になっていたので、間接的ながら何か貢献出来るのがコミットするモチベになった。やってることは一般コントリビューターの頃からあまり変わらないが、メンテナーから直接タスクが飛んできたり、PRのマージやissueのクローズが出来るようになったので「これやっちゃっていいっすか?」みたいな英語を書いて動いたりしてる。JS から TS にするのにまだ手が足りてないのでみんなやってほしい、おれがレビューでも良ければですが......始めて OSS をメンテナンス出来る側になったので付き合い方とかで色々最初はあたふたしてたが、みんな issue で割と気軽に話題振ったり、discord でカジュアルに話してたり、想像よりも良かった感じがする。もうちょっとコミットの頻度を上げて細々ながら続けていけたらいいなと思う。エンジニア業OSSとはまた別で受託した案件に入っている。去年は若干デザインもやっていたが、今年はエンジニア専門としてデザインから実装への落とし込みをするような振る舞い方になってきた。まだ公開していないので何も話せないが、久々に設計とかちゃんとせんとなってなって色々試行錯誤している。誰かdan先生の この記事 を毎年更新するやつやってほしい。もう誰もHoCの話しとらん。こういう見た目/ロジックに分けるパターンがやっぱりReact Component構成の1つの正解(dan先生の記事か https://www.wantedly.com/companies/wantedly/post_articles/302873 か https://moneyforward.com/engineers_blog/2020/02/18/react-component-rules 見てください)というのが総意な気がしてきた。ちなみに前述の https://github.com/pmndrs/react-three-next でも、コマンドでstyled-componentsを選択すると、これ風味のNext.jsプロジェクトが出来上がる。あとやっぱりどうあがいてもAtomic Designの影から逃げ出せる気がしない。なんとなくやっつけでやりました 構成が1番許しがたいため、理由のある構成を考えた結果やっぱりこれに近づいていく。状態を持つか持たないかだけ考えてAtomsとPagesでどうにか出来るんじゃないかみたいなことを最近は思い始めた(https://zenn.dev/takepepe/articles/atomic-redesign これもみて)。卒論ヤバいことを序盤にしなかったので放任された結果自己管理の甘さから後々ヤバいことになったきたタイプ。義務教育の間は神童扱いその後は適切な手の抜き方が分からなくて詰むみたいなのと同じ。教授の神がかった記憶力によってなんとか最低ラインは維持できている。「そういえばWebで動くものの開発ができたよね?」と研究室の自己紹介でちょろっと話した内容をちゃんと覚えてくれていて、それ関連で研究が進んでいる。研究としては音声から色々推定するやつをやっている。人もすなる機械学習というものをおれもしてみむとてするなりになってきた。進捗に関しては触れないでほしい。他ブログ運用構成をちょっといじった。Cloudflare Pagesが正式にAPI対応してくれたおかげで、ContentfulでDeploy Webhookを組めるようになったので試した。やり方は https://dev.classmethod.jp/articles/cloudflare-pages-api/ にきれいにまとまっていたので参考にしてほしい。feed用のxmlを手動更新する必要がある以外は特に問題なくデプロイ出来てる。おわりに東京喰種読んだ中学生みたいに「この世の不利益はすべて当人の能力不足」って連呼しながら筋トレして毎日過ごしてます。
  • TSとPHPでFormDataの型を共有する

    2021/07/03

    背景https://twitter.com/did0es/status/1401471990259613697サーバーサイドで Node.js が動かない環境(某レンタルサーバー)でアプリケーションを実装する必要が生じたため、渋々 PHP を書いていたんですが、クライアントサイドは React で書きたいという我儘が出てきてしまい、その欲望に従った結果型を共有出来ないゆえの地獄を見たのでその脱出を図りました。概観筆者は FormData を扱う際に以下のような型の拡張を書いたd.tsファイルを使うことがあります。interface FormData { append( name: 'foo' | 'piyo' | 'fuga', value: string | Blob, filename?: string ): void; } append の name を文字列リテラルで縛っています。サーバーサイドも TypeScript の場合この型定義を共有することで、クライアントサイドから送信される FormData の name がどのようなものか簡単にわかるようになっています。また VSCode などでは補完機能により、タイポを防いで無駄な確認の手間を省くという意味合いもあります。ただサーバーサイドが PHP の場合(TypeScript 以外の言語でもそうですが)、d.ts ファイルを共有出来ないため、何かしら別の手段を用意する必要があります。今回は PHP なので、PHPDoc Types と PHPStan の静的解析による型チェックを活用していきます。用意するものクライアントサイドは TypeScript で書かれている前提で、他にサーバーサイドでは以下のものを用意します。PHPStanPHP-Parserどちらも composer でインストール出来ます。実装FormDataを拡張した型を取り出してJSONにするFormData を拡張した型定義は以下のようになっています。formdata.d.ts interface FormData { append( name: 'audio_data' | 'user_dir_name' | 'data_map' | 'result_data', value: string | Blob, filename?: 'data_map' | 'listen_result' ): void; } 上のコードから name の Parameters を取り出して JSON にします。数行程度で実装出来ます。types-bridge/src/core.tsimport * as path from 'path'; import * as ts from 'typescript'; import { readFile, writeFile } from 'fs/promises'; type Properties = { [key: string]: string }; let properties: Properties = {}; const visit = (node: ts.Node | ts.Node[], sourceFile: ts.SourceFile) => { if (Array.isArray(node)) { node.forEach((n) => { visit(n, sourceFile); }); } else { if (node.kind === ts.SyntaxKind.Parameter) { const texts = node.getText(sourceFile).split(': '); const key = texts[0]; const value = texts[1]; properties[key] = value; return; } else { visit(node.getChildren(sourceFile), sourceFile); } } }; const extract = async (file: string) => { const data = await readFile(path.resolve(../client/src/${file})); const sourceFile = ts.createSourceFile( file, data.toString(), ts.ScriptTarget.ES2015 ); visit(sourceFile.getChildren(sourceFile), sourceFile); await writeFile( process.cwd() + '/json/formdata.json', JSON.stringify(properties, null, ' ') ); properties = {}; }; extract(process.argv[2]); visit関数によって型定義の TypeScript AST を Traverse し、該当する SyntaxKind を取り出してオブジェクトに格納しています。オブジェクトは後に PHP側 の実装と共有するため JSON として書き出しています。別に JSON じゃなくても PHP 側でも読み込めるファイルであればここは何でもいいです。PHP側でJSONのデータをPHPDoc Typesに変換POST された FormData は PHP 側で $_FILES から受け取ります。定義済み変数の型を拡張する方法がわからなかったので以下のようなクラスを定義しました。(こうすれば拡張できていいよ!とかあれば教えて下さい)server/src/shared/http_variables.php <?php declare (strict_types=1); error_reporting(E_ALL); class HttpVariables { // public function files($key) { return $_FILES[$key]; } } この状態で PHPStan を実行すると落ちます。(PHPStan の level は  max で実行しています)> ./vendor/bin/phpstan analyze -c phpstan.neon 9/9 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100% ------ ------------------------------------------------------------------------------ Line shared/http_variables.php ------ ------------------------------------------------------------------------------ 18 Method HttpVariables::files() has no return typehint specified. 18 Method HttpVariables::files() has parameter $key with no typehint specified. ------ ------------------------------------------------------------------------------ [ERROR] Found 6 errors Script ./vendor/bin/phpstan analyze -c phpstan.neon handling the phpstan event returned with error code 1 この PHPStan による型チェックが通ることをゴールに進めます。files メソッドの @return の型はとりあえず以下のように設定しておきます。/** * @phpstan-type Files array{tmp_name: string, name: string} */ class HttpVariables { // /** * @return Files */ public function files($key) { return $_FILES[$key]; } } @param の方は先程用意した JSON を元に作成します。以下が実際に実装したコードです。server/scripts/types-bridge-client.php<?php declare(strict_types=1); error_reporting(E_ALL); require(dirname(FILE) . "/../vendor/autoload.php"); use PhpParser\\Comment\\Doc; use PhpParser\\Error; use PhpParser\\ParserFactory; use PhpParser\\Node; use PhpParser\\Node\\Stmt\\ClassMethod; use PhpParser\\NodeTraverser; use PhpParser\\NodeVisitorAbstract; use PhpParser\\PrettyPrinter; $target_php_file = 'http_variables.php'; $target_types_file = 'formdata.json'; $target_php_file_path = DIR . "/../src/shared/" . $target_php_file; $target_types_file_path = DIR . "/../../types-bridge/json/" . $target_types_file; $php_code = file_get_contents($target_php_file_path); $formdata_types_json = json_decode(file_get_contents($target_types_file_path)); $parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7); try { $ast = $parser->parse($php_code); } catch (Error $error) { echo "Parse error: {$error->getMessage()}\\n"; return; } $traverser = new NodeTraverser(); class NodeVisitor extends NodeVisitorAbstract { private $formdata_types_json; function __construct($formdata_types_json) { $this->formdata_types_json = $formdata_types_json; } public function enterNode(Node $node) { if ($node instanceof ClassMethod && $node->name->name === 'files') { $prev_doc_comment = $node->getDocComment()->getText(); $prev_doc_comment_lines = preg_split('/\\n/', $prev_doc_comment); $formatted_comment_lines = array_map(function ($line) { return trim(preg_replace('/\\*/', "", $line)); }, $prev_doc_comment_lines); foreach ($formatted_comment_lines as $line) { if (preg_match('/@param/', $line)) { $words = preg_split('/\\s/', $line); $param_name = $words[array_key_last($words)]; $param_types = $this->formdata_types_json->name; $new_doc_comments = "/** * @param $param_types $param_name * @return Files */"; $node->setDocComment(new Doc($new_doc_comments)); } } } } } $traverser->addVisitor(new NodeVisitor($formdata_types_json)); $ast = $traverser->traverse($ast); if ($ast) { $pretty_printer = new PrettyPrinter\\Standard(); file_put_contents($target_php_file_path, $pretty_printer->prettyPrintFile($ast)); } else { throw new \\Exception('Cannot get ast.'); } PHP-Parser の PhpParser\\NodeTraverser を利用して、対象の PHP ファイルから files メソッドの PHPDoc Comment を探し出し、getDocComment で取り出しています。取り出したコメントは整形した後、@param の型に相当する部分を読み込んだ JSON を元に setDocComment で置き換えています。試しにこの files メソッドの戻り値から目当ての値を呼び出してみます。<?php // $http_variables = new HttpVariables(); (function () use ($mkdir, $http_variables) { $files = $http_variables->files('result_data'); $input = $files['tmp_name']; $output = $files['name'] . ".json"; if (move_uploaded_file($input, "./" . $mkdir->full_data_dir_name . "/" . $output)) { echo json_encode(['message' => 'Created listen result data.']); } else { http_response_code(405); } })(); PHPStan による型チェックは通ります。$ composer --working-dir=workspaces/server phpstan > ./vendor/bin/phpstan analyze -c phpstan.neon 9/9 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100% [OK] No errors ✨ Done in 0.69s. 🎉ちなみに呼び出す際の key を先程生成した PHPDoc Types に存在しないものにするとちゃんとチェックは落ちます。<?php // $http_variables = new HttpVariables(); $mkdir = new Mkdir($http_variables); $mkdir->set(); (function () use ($mkdir, $http_variables) { $files = $http_variables->files('something'); $input = $files['tmp_name']; $output = $files['name'] . ".json"; if (move_uploaded_file($input, "./" . $mkdir->full_data_dir_name . "/" . $output)) { echo json_encode(['message' => 'Created listen result data.']); } else { http_response_code(405); } })(); $ composer --working-dir=workspaces/server phpstan > ./vendor/bin/phpstan analyze -c phpstan.neon 9/9 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100% ------ -------------------------------------------------------------------------------------------------------------------------------------- Line listen/set_result_data.php ------ -------------------------------------------------------------------------------------------------------------------------------------- 18 Parameter #1 $key of method HttpVariables::files() expects 'audio_data'|'data_map'|'result_data'|'user_dir_name', 'something' given. ------ -------------------------------------------------------------------------------------------------------------------------------------- [ERROR] Found 1 error Script ./vendor/bin/phpstan analyze -c phpstan.neon handling the phpstan event returned with error code 1 おわりにサーバーサイドが TypeScript でない状況でもどうにか AST の力を借りて型を共有してみました。PHP の $_FILES 以外に $_GETや $_POST でも同様に拡張した型定義があれば FormData 以外でも PHP 側に型を流すことが出来ると思います。実際に実装したリポジトリなどは公開していないのでお見せ出来ないんですが、何か質問やまさかりなどあれば shuta までお願いします。
  • 「OSSのすすめ」というLTを行った

    2021/06/11

    所属している情報系のサークルで「OSSのすすめ」というタイトルの LT を行いました。意図僕自身 OSS に関してそこまで大きな成果を残しているわけではないんですが、今後 OSS に関わりたいという人に向けての一助としてこの LT を行いました。また、抱いていた問題感として OSS を使うだけ使ってそれの良くない点を述べるような記事や発言を目にする機会がまあまああったので、それに対して投げつけるまさかりとしての意図もあります。想定している視聴者は OSS に関心があるが何をすればいいのかわからない方です。日常的に深く OSS に関わっている方からすると浅い内容かもしれないと思うので、どうぞよしなにお願いします。概要冒頭のスライドの補足を行っていきます。まずこのスライド全体で伝えたいことは以下の2点です。気軽に OSS に関わってほしいただの批評家になるな気軽にOSSに関わってほしい何か OSS に関わりたいが、どうすればいいかわからないという人向けに作成した LT ということもあり、OSS に関わるプロセスを僕が行った例を提示して述べています。特に良くない例と良い例を示すことで、具体的にどうやって OSS に関わっていくのかを想像しやすくしています。ただの批評家になるな以下の3枚がこのスライドの趣旨にあたります。一番伝えたかったのが3枚目で、何かしらの OSS の問題点を見つけたならそれを解決する方向に動いて欲しいということです。何かを批評するのは簡単ですが、それを行動にまで起こせるとさらに学びがあったり、回り回って誰かのためになることもあるので積極的にやってくれ!ということをスライドを通して伝えようとしました。伝わったかはわからないです。おわりに一般エンジニアから見た「OSSのすすめ」ということで LT を行いました。OSS を批評することは別になんでもいいんですが、ぜひ問題感を抱いたなら解決する方向に動いて欲しいです。また「何かしら OSS に貢献したい!」という人がこれを見て「よしやるぞ!」となってくれたのであれば嬉しい限りです。これからも先駆者や創造者に感謝しつつ OSS やっていきましょう。
  • ブログをNetlifyからCloudflare Pagesに移行した

    2021/05/16

    今まで Netlify でホスティングしていたブログを Cloudflare Pages に移しました。以下所感などです。良かった点CloudflareのWeb Analyticsが使えるようになったhttps://developers.cloudflare.com/analytics/web-analyticsこういったアナリティクスを Cloudflare 側から公開した Pages のサイトに導入することが出来ます。Web Vitals も確認出来ます。アナリティクスとデプロイが一体化しているのでさっと確認出来るのが良いです。また今まで Netlify では Google Analytics を使っていたので、外部からアナリティクスのトラッキング用タグをつけるためのスクリプトを読み込んでいたんですがその必要がなくなったのも良かったです。QUIC Protocol, HTTP/3に対応したご存知の方も多そうですが QUIC は Google による実験的なプロトコルの1つです。HTTP/3 の文脈でよく出てきますね。結構色々なブラウザや CURL が実験的に対応を進めているようなので、そろそろ正式にサポート来るんじゃないかと思って急ぎ目の移行に踏み切りました。https://developers.cloudflare.com/http3/#supported-clientsCloudflare だと header の alt-svc に以下のように h3-29 が付与されるので対応が出来ます。これで Chrome の Experimental QUIC Protocol を有効にすると更に早くブログが表示されるはず...ブラウザの HTTP/3 対応が進んでも慌てなくて済みました。無料枠がNetlifyよりも広め1ユーザーの感想という感じですがやっぱり広いと嬉しいです。以下比較です。Netlify https://www.netlify.com/pricingCloudflare Pages https://pages.cloudflare.com/#pricingCloudflare Pages はまだ機能が少ない分項目も少ないんですが、Build minutes が Netlify の 1.5倍ぐらい余裕があります Cloudflare の方は 500回/月 という書き方でしたね。今の所このブログのデプロイにかかる時間は、 Cloudflare Pagesが1分40秒、Netlify が2分6秒で Cloudflare Pages の方が速いです。なので単純計算 Netlify が150回ビルド/月という感じになっています。使用するフロントエンドのライブラリによってビルド時間は変わるかもしれないので、一概にこっちがいい!みたいなのは言えないですが、割と余裕を持ってWebサイトのホスティングが出来そうです。良くなかった点ContentfulのWebhookでビルド出来ないCloudflare Pages は CI/CD 向けの API を今の所提供していないので Contentful で記事更新するとビルドする、が出来ないです。以下の Cloudflare Pages についての紹介を見ていると、CI を組まなくてもいいようにしたいよね!みたいなことが押し出されているので、おそらく今後も API 公開はないのかなと思いましたhttps://blog.cloudflare.com/cloudflare-pages/Most CI tooling, however, is quite cumbersome, and for good reason — to allow organizations to customize their automation, regardless of their stack and setup. But for the purpose of developing a website, it can still feel like an unnecessary and frustrating diversion on the road to delivering your web project. Configuring a .yaml file, adding and removing commands, waiting minutes for each build to run, and praying to the CI gods at each one that these are the right commands. Hopelessly rerunning the same build over and over, and expecting a different result.(現場にはよく CI の神様がいるようです🤔)が、一応 Cloudflare のコミュニティフォーラムで API 提供する予定はあると言及されていたので動向は追うつもりです。https://community.cloudflare.com/t/build-hooks-for-cloudflare-pages/262474その他パフォーマンス移行後、改めて Netlify と Cloudflare Pages でホスティングしたもの両方で Lighthouse を何回か回してみたんですが大差なかったです。Netlify の無料プランでは東京リージョンの CDN が使えないと聞いていたので、初回応答速度とかで結構差がつくかなと思っていたんですがこの規模だとそこまでありませんでした。どちらを使うかはほぼ好みの領域だな〜という感じですが、ブログぐらいならパフォーマンスはほぼ変わらないので今の所 Netlify の方が deploy hook 組めたりする分いいのかな...ついこの間まで Cloudflare Pages はベータだったので、これから色々充実してくるものだとは思います。Next.jsのISRやめたNetlify でホスティングしていた際ブログのエントリーにアクセスしたとき、今までは ISR でブロクの更新内容が fetch されるような設定を行っていました。これによって Contentful で更新をかけると、ユーザーからのアクセスに紐付いてビルド無しでブログ本体の方も更新されていたんですが、ページ遷移した際のロードが遅くなる原因になっていたのでやめました(Link に prefetch=false つけてごまかしてたんですが、もっさり具合をどうにかしたい欲が出ました)。結局普通の SSG したものを Cloudflare から配信しています。また SSG して運用するのと合わせて Next.js の最適化のため unstable_runtimeJS オプションも試してみました。 詳しい情報はこちらの PR を見てください。https://github.com/vercel/next.js/pull/11949#issuecomment-615140664これでトップページは Lighthouse のスコアが満点になりました(Performance が 94 → 100 に上がりました)。おわりにとりあえず個人ドメインも全部 Cloudflare に移行完了したので、色々と変更が入るのを待ちながら静的サイトづくりをやっていきます。
  • VJに入門した

    2021/05/03

    今更ながら VJ に入門しました。ブィジェー多分わかってきた https://t.co/tndDjZSfJw">pic.twitter.com/tndDjZSfJw— Shuta (@did0es) https://twitter.com/did0es/status/1389143569672273925?ref_src=twsrc^tfw">May 3, 2021動機前々から気にはなっていた VJ ですが、敷居が高いというかやるなら時間をかけてじっくりやりたいと思っていたので、ちょうどいい感じにまとまった時間が取れる GW 中にはじめました(とは言ってももう GW 終わりそうですが)。他の動機として、ちょっと作ってみたい Figma 向けの VJ プラグインを思いついたので、その調査と要件定義も兼ねて VJ ソフトを触ってみたいと思ったのもあります。VJ 入門は VJ ソフトを作るのが一般的(???)みたいなのでやっていきます。これはまた完成したときに別記事で詳しく書くはず使っているものVDMX5適当にインターネットサーフィンで得た動画素材mac のデフォルトマイクVDMX 自体はド定番だと思います、みんなが通る道なので歩きやすい。VDMX5基本的な使い方とかは YouTube で解説している人(たいてい海外の人なので情報量たっぷりマシンガントークの英語)の動画を見様見真似でやってます。若干 UI とかは変わってても、やってること理解するように心がけるとサクッと出来ました。過去に DTM もちょっと真面目にやってたので、LFO あたりの音声を扱う部分は結構勘でもどうにかなりました。VJ 入門で1番敷居になったというか困ったのが動画素材でした。色々と調べまわった結果、入門には Beeple の動画を使う感じになりました。CC ライセンスなので、割と使い勝手が良さそうです。ちなみに Beeple の音源 zip ファイルを解凍するとき、macOS Catalina 10.15.7 だと Finder では何故か失敗して unzip だとうまく出来ました。なにかの参考にどうぞ。やったこと冒頭のツイートにある 20 秒程度の動画では以下のことを試しました。レイヤーの加算crossfadeその他動画への FX 適用レイヤーの加算はじめは 3 つレイヤーを作って加算していましたが、途中で crossfade やりたいな〜と思ったので 2 つに減らしました。以下のようにレイヤーごとにプレビューや FX 適用のパネルを作っています。ここらへんの動画を参考にしました。opacity を入力音声に紐付けると 0 ~ 1 の範囲で加算される感じです。crossfade・切り替わるときに FX 適用opacity を変える方針だと、画面がかなり眩しいのと動画がごちゃごちゃしてあまり良くなかったので crossfade に切り替えました。この記事を参考に crossfade と切り替え FX を作成しました。最近よく使うVDMXの切り替えエフェクトを共有以下のように LFO の波形をいじって、切り替わる瞬間に画面が揺れるようなエフェクトを作っています。細かい部分ですが、入れるだけでかなりダイナミックな感じになって良いです。他に入力音声の周波数の低音成分の変化をトリガーに、動画を10秒ずつ前後させる automation も付与させてドロップでの動きを増やしつつ、何度も同じ画面が映らないようにしています。動画への FX 適用Beeple の素材そのままで使うのも面白くないので、色々と FX を試してみて良さそうなものを使いました。VDMX では個人的に Mirror が気に入っています。中央を縦向きに分割して鏡写しにするような FX です。また Blur 系は始めの方よく使っていたんですが、 輝度を変えるとかなりぼやけてしまって良くなかったので opacity で調整するようにしています。他に Hue や RGB 値をいじって 動画の色味を近づけてみたりもしました。今後VDMX は今の所 VJ 入門用として無償版を使っているんですが、有償版にアップグレードしてしまってもいいかなと感じました。やっぱり保存出来ないのつらいです...。こういうソフトでちまちま GUI いじるのが好きなのでより深めていきたいです。有償版に上げたら MIDI 入力デバイスで動画を変化させてみたり、自作のシェーダーを FX に適用したりあれこれやってみようと思っています。また動機で少し触れたように Figma 向けの VJ プラグインの方も実装着手していきたいと思います。まだ着地点が見えないのでどうなるか自分でもわかりませんが、実用的なプラグインになるように VJ の知見を集めつつ進めていきます。
  • 毎週映画を見る時間を取るようになった

    2021/04/20

    背景最近意図的に映画を見る時間を取っている。映画を無性に見たくなったからというわけではなく、人の話についていきたいから倍速で見ることもなく、映画を見ている間は別のことをやらない時間にして強制的に休息を取るために始めた。本を読んだりゲームで発散したり、趣味でなにか作るとかでも良かったが、あまり集中しなくても出来ることを選んだ。というのも去年の冬ぐらいに受けていた仕事でオーバーワーク気味になり、精神的に参っていた時期があったためそれの解消を図った。毎日休日込みで長時間作業していると他のことが手につかないのもあるが、好きでやっていたコードを書くことが嫌いになりかけたのが1番つらかった。時間がない・タスク山積み・非同期的なコミュニケーションで毎日問題が発生(これが1番デカい)の三重苦で、常に緊張した感じが続いており、コードを書くことを楽しむみたいな気持ちがどこかへいってしまった。楽しさを求めてやっていると言うと「仕事をなめるな!」って言われそうだが、自身の行動の原動力がそれなので失うと非常に困った。技術を追う気力もそこから出てくるので、しばらくインターネットすら開きたくない、みたいな状態になっていて(Slack と Gitlab 以外本当に開いていなかった)結構まずかった。紆余曲折あり今年になってようやく落ち着いて他のことを考えられるようになったので、時間を捻出してでも余裕を持って動こうという感じになった。結果まとまった時間を別のことに使う前提で予定を組むと、精神的に余裕が出たのでかなり良かった。追い込まれながら作業するのもハードトレーニングみたく、成長を促すのには必要なときもあるが、やりすぎると心身に悪い影響が出るので適度に抑えた方が良いと思う。なにより楽しむ気持ちが戻ってきたのが1番安心した。あと日頃映画を見に行く習慣がほぼなかったので、異文化交流みたいな感じで色んなものに触れられたのも新鮮で良かった。以下見た映画に対して感じたことや思ったことをまとめておく。最近見た映画Netflix か Prime Video に上がっている洋画を字幕付きで見ていた。ついでに英語力上げるぞ〜とか思って最初は字幕付けてなかったが、疲れてすぐに付いているのに変えた。インセプション前情報なしで見ていたがレオナルド・ディカプリオとか渡辺謙、あとダークナイトのロビンの人(名前忘れた)が出てて、もしかしてかなり有名なやつ?ってなった、有名っぽい。前半折り返すぐらいまでずっと渡辺謙が悪役だと思っていた。あとヒロインが友達に似ててそればっかり気になった。冒頭で迫真の日本語が聞こえてくるので一瞬間違って吹替版を見ているのかと思った。お使いの設定は正常です。見始めると渡辺謙の英語がかなり流暢で羨ましくなった。映画としてはアクションシーン多めSF。基本夢の中の話なのですごい形になった建物とかが出てきて、耐震基準考慮なしで設計するの楽しいだろうなと思った。夢の中でやりたい放題するみたいなのはよくあるけど、現実と区別がつかなくなってくるみたいな描写がよかった。はじめはやらかしたらおしまいみたいな緊迫したシチュエーションだが、結局色々やらかして後半面白くなっていく。自分も夢でやりたい放題するときはトーテム用意しようと思う。インターステラーSFもの。アルマゲドンみたいな感じかと思っていたが、まさかのサスペンス要素がある。ただのSFだとウケないのでSFも色々進化してきたっぽい。SFと同時に家族の愛情を描いた(かなり主人公が不憫な)作品でもあるので、将来また見返したら面白いのかもなとも思う。人間が知らない想像を超えた部分がテーマなので、結構ダイナミックな描写が多い。1個目の惑星のデカい津波のシーンとか、自分がその場にいたら相当パニックになって固まりそう。まあどの星にいってもそれなりにパニックになるとは思う。惑星での1時間が地球上では何10年に相当するし。途中主人公がブラックホールに突っ込んでからは抽象的な話が続くが、前半の謎の描写の答え合わせになっている。5次元なので何でもアリらしい。どう終わるかなと思っていたけど、割ときれいに幕引きされたのであと残りなくてよかった。多分あの準ヒロインみたいな博士は助かる。気になったところとして人が住めるほどのコロニーを作るのって、人間が滅びかけの状況だとほぼ不可能に近くないかと思う、単純に人手が足りなそう。無人機にやらせるのかな、建築別に詳しくないのでただの憶測だけど。あと地球から離れることになるので、相応の発電設備とか水とかを確保する仕組みが無いと、コロニーが作り上がったとしても永住は出来なさそう。作品の中では特に触れられていないので、まあ当然のごとく出来る仕組みはあるっぽい。原子力か反物質使った何かかな。他に宇宙飛行士とそのサポートをするロボットの会話が面白かった。僕も最後の最後まで軽口を叩いて終わっていきたい。オーシャンズ シリーズシリーズが 11 から始まるのを知らなくて、8 から先に見ようとして危なかった。とりあえず11 → 12 → 13 → 8で見た。強盗しながらほぼ常に何か口の中にいれてもぐもぐしているブラッド・ピットを見る映画。もの食ってるだけでかっこいいブラッド・ピットずるすぎる。他にジョージ・クルーニーなど、ろくに洋画見ていない自分でも知っている人が出てきた。強盗といっても銃撃戦みたいなのはほぼなく、ルパンの次元五右衛門抜きみたいな感じ。とにかく考えてものを盗む。盗む目的も金を荒稼ぎ!みたいな動機だけではなく、盗む計画を立てることを楽しんでいる感じがする。どの回見てもおもしれーってなるけど自分は 11 が好き。なおオーシャンズ8はほとんど違う作品になっているので注意、もぐもぐするブラピは見れなかったのでちょっと残念。でもアン・ハサウェイがめちゃくちゃ目の保養になったのでなんでもいいです。シャーロック・ホームズ昔小説で読んだような気がしていたが忘れたのでほぼ初見だった。1作目はワトソンとシャーロックの連携の取れた謎解きが見れるが、2作目からはシャーロックが廃人になる(ワトソンとグダグダ連携謎解きはやっている)ので純粋にワトソンが大変そう。後半で復活する。探偵もので謎解きがメインの印象があったが、映像化すると結構派手でアクションとしても映えるんだなと思った。分類するとコメディのジャンルにも属する気がする。機関銃にシャーロックがパクってきた口紅入れたりするし。1作目か2作目か忘れたけど、どちらかで出てきた「闇の力〜」とか言ってた独裁者崩れみたいなのが痛くてよかった。ちょっと時代設定が古いから魔法(科学)使いみたいな立ち位置なのかな。まだドラマシリーズ見ていないので完結してそうだったら見る。ジョン・ウィック圧倒的な弾数でどうにかするタイプのキアヌ・リーブス。ジャンルとしてはアクションがメイン?ヤクザ映画みたいなのとはちょっと違うと思う。マトリックスみたいに弾は避けない(そもそもほぼ当たらない)、当たる時はだいたい脇腹に食らう、痛そう。モブの弾が本当に当たらないので、多分主要人物以外銃のバレルが曲がっているんだと思う。とにかくキアヌ・リーブスの銃撃戦が見たい!って人におすすめ。復讐の復讐みたいな感じで次の作品に続いていくので雪だるま式に敵が増えていく。3作目(パラベラム)の掟に歯向かった後に出てくる寿司屋がめちゃくちゃ強い、でも増えた敵は全部倒す。なんだかんだ今年新作が出るらしいのでちょっと楽しみにしている。天使と悪魔1作目がダ・ヴィンチ・コードらしい、これ見た後に知った。ダ・ヴィンチ・コードは多分みたことあるけど覚えてなかった、けど普通に楽しめた。ミステリーにSF(科学)・宗教を突っ込んだ何でもアリ作品。本当にバチカン市国で撮影していたっぽくて、あの面積淡路島の国にめちゃくちゃ教会が存在するんだなと思った。バチカンの風景キレイだなーと思っていると、急に残忍なシーンが出てくるので油断できない。SF要素として何故か反物質が出てくる。後々教会との関わりで、科学側の教会(宗教)への報復としてとりあえず爆発させようとしてくる。ラストは正義マンです!みたいなやつがダークナイトよろしく空に反物質爆弾持っていって爆発、なぜか助かるところまでダークナイトと同じで面白かった。インフェルノが続編なので近々見ようと思っている。パラサイト最近見た中で1番面白かった。場面がサクサク切り替わって進んで行くにつれて、渦中の家族が狂っていくのがよかった。家庭教師の長男と生徒の恋愛があんなにサクサク進むものかなとは思ったけど、あれぐらいテンポよく進むと見やすい。計画を立ててうまくいって、調子に乗ってやらかす展開がきれいに起承転結って感じなのもよかった。ラスト手前の地下から無敵の人が出てきて、全ておしまいです!という感じが潔い。韓国の作品は触れるとしても最近の音楽ぐらいしかなかったが、映画もかなり押していて勢いがあるなと感じた。映画内の演出だと富豪と貧民の対比が地上と半地下以外にも、カメラワークとか家族の位置関係で表現されている場面があって、細かいところまで手が込んでいるなと思った。映画ポスターのデザインも大胆で日本っぽくなかった(日本版のポスターでは日本の映画ポスターです、になってしまったけど)のもよかった。あとはスイスの民家に核シェルターが搭載されている話は有名だが、韓国でも似たようなものを持っている層もいるんだと思った。そして大体ワインセラーとか知らない人の住処(?)とかになっている、平和は良いですね。マトリックス主演の好青年がキアヌ・リーブス。本当に?ってなるぐらい好青年。背中床すれすれで弾を避ける例の作品。キアヌ・リーブスは銃撃ちまくっている印象が強かったが、マトリックスでは割と武道にも打ち込んでいる印象だった。ラスト付近は強くてニューゲームみたいになるので、何でもアリなキアヌ・リーブスが無双する(ので、1作目以外は見ていない...展開の予想がつくので気が向けば見る)。大学の教授がなにかと授業のネタに使うので、ほぼネタバレされた状態で見ることになったがそれでも面白かった。だいたい自分が生まれたぐらいの時代に作られた映画なので、ガラケーがバリバリ現役でやっていて感動した。他に未来の発明みたいな感じで、侵襲性のある BCI みたいなデバイスが出てくるが、脳に挿して抜いて繰り返すので衛生面大丈夫か心配だった。また作中で EMP が出てくるが、手に持って投げる爆弾ではなく思いっきり船から放出みたいな形だったので豪快だった。おわりに週1 ~ 2ぐらいの頻度で映画見て休息を取るのおすすめです、目が疲れるけど。見たものを言語化しておきたいためにこの記事を書いたのでまた更新するかもしれない。
  • あらゆるものから逃げるReact+WebGLテンプレートを作った

    2021/04/09

    React+WebGL で SPA の何かしらを作る際のテンプレートを作りました。あらゆるものから逃げます。リポジトリはこちら。Netlify にプレビュー放り投げたのでこちらもどうぞ。https://did0es-react-twgl-boilerplate.netlify.app構成React を使いつつ、CRA を使わず webpack のコンフィグを書いています(どうせ eject するので webpack から書いた)。React (17.x)Webpack (5.x)Babel (17.x)TypeScript (4.x)Linaria (2.x)Universal Router (9.x)TWGL.js (4.x)何から逃げたのかまず前提として純粋なSPAを作りたいと思ったので、 Next.js は使わない方針でいきました。 Next.js は SSG/SSR するときの踏み台にしたい気持ちがあります。以下のものから逃げました。react-routerthree.jsCSS Modules, styled-component逃げた理由react-routerreact-rouer は使う時に手の届かないところがあるとか、破壊的変更を繰り返しているのでついて行くのに疲れたからといった感じです。あとは universal-router を試してみたかった気持ちもあります。SPA でページ遷移を表現するには history とか location 周辺を自分で書く必要があるので、そういった部分の学びになりました。完成したものが以下のリンクから見れます。isomorphic さはそこまで求めていなかったのですが、path-to-regexp でかなりシンプルな Router ということで使い勝手が良さそうというのが所感です。react-twgl-boilerplate/src/routesthree.js以下のように非常に苦しみました。https://twitter.com/did0es/status/1355506728507371520バンドルサイズを減らそうと思ったのですが tree shaking がまともに出来ない webpack の plugin で tree shaking は出来るみたいなんですが、平面に GLSL 描画するだけにしか three の用途がなかったので、もう少し軽量な TWGL に逃げることにしました。以下は React で TWGL.js を使う際のコードです。// sourced from <http://twgljs.org/examples/tiny.html> import { createBufferInfoFromArrays, createProgramInfo, drawBufferInfo, resizeCanvasToDisplaySize, setBuffersAndAttributes, setUniforms, } from 'twgl.js'; import { css } from '@linaria/core'; export const Canvas: React.FC = () => { const onCanvasLoaded = (element: HTMLCanvasElement) => { if (!element) return; const gl = element.getContext('webgl'); if (gl == null) return; const programInfo = createProgramInfo(gl, [ require('./shaders/vertex.glsl').default, require('./shaders/fragment.glsl').default, ]); const arrays = { position: [-1, -1, 0, 1, -1, 0, -1, 1, 0, -1, 1, 0, 1, -1, 0, 1, 1, 0], }; const bufferInfo = createBufferInfoFromArrays(gl, arrays); function render(time: number) { if (!gl?.canvas) return; resizeCanvasToDisplaySize(gl.canvas as HTMLCanvasElement); gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); const uniforms = { time: time * 0.001, resolution: [gl.canvas.width, gl.canvas.height], }; gl.useProgram(programInfo.program); setBuffersAndAttributes(gl, programInfo, bufferInfo); setUniforms(programInfo, uniforms); drawBufferInfo(gl, bufferInfo); requestAnimationFrame(render); } requestAnimationFrame(render); }; return <canvas className={wrap} ref={onCanvasLoaded} />; }; const wrap = css` width: 100%; `; WebGL API を叩いたときほど書く量は減ったのではないでしょうか。 three ほどライブラリの容量もないので快適です。three の軽量化について試してはいないんですが調べるだけ調べました。Three.js の issue の discussion とか、 この記事 (日本語です) を見てもらうとわかるんですが、import { Foo } from 'three' をしても three.js 全体を読み込んでしまうので、デフォルトで tree shaking は機能しません。こういった状況を打開するため、 webpack / rollup 向けの three-minifier という plugin があるようなので、どうしても three で tree shaking をしたいなら試してみるといいかもしれません(THREE.WebGLRenderer 単体のサイズが大きいので、 tree shaking した恩恵をあまり受けられないような気もしています)。他に three を使わなくなった理由などthree 本体の動きとして、型定義を削除したPRがマージされた ことがあり、若干の不信感が募っていました。The side effect of that decision was a considerable increase on maintenance burden and made things harder for new contributors too. For the sake of continuity of the project I think it's best to move them back to DefinitelyTyped (or any other repo interested in maintaining them).Mr.doob 氏曰く、とにかくメンテが辛かったので負荷を減らしたかったらしいですね。お疲れさまでした。three の型はもういいとして、平面に GLSL を描画する以外のことがしたくなったらまた戻ってくるかもしれないですが、しばらくは TWGL 使うつもりです。CSSCSS が JS のランタイムに組み込まれるのは嫌なんですが、 CSSinJS はやりたかったので linaria を使いました(CSSはファイルとして読み込んでほしい)。おわりにNext.js に頼らないかつ最小の構成で React + WebGL を組みました。その他の疑問点や展望については以下のとおりです。画像最適化適切なチャンク分割GLSL ファイルを読み込むための raw-loader動的な routing 対応まだ劣化 Next.js に WebGL の環境のせましたという感じなのでここらへんやっていきます。
  • Next.js の ISR/SSR で Contentful から直接 Netlify にデプロイしないブログ運用

    2021/03/31

    Contentful からの デプロイ を無くし Netlify のビルドリソースを節約することで、無料枠を使い切る心配を減らすといった、ブログ記事執筆のモチベを損なわない方法です。前置きブログのように記事を API として Contentful から受け取り Netlify で SSG としてビルドする際に、 Contentful の Netlify 用 Webhook を作成 -> publish するとデプロイ といった運用を行うことがあります。このような運用において記事を新たに作成誤字脱字の修正内容の更新などの編集を繰り返すと Webhook による デプロイのため、Netlify のビルドリソースを使い切りそうになるような状況が起こり得ます。特にブログ自体を作成している途中であれば、コード自体の push 量もそこそこにあるため、 Netlify のビルド上限には注意する必要があります。この状況を Next.js の ISR によって対策することを試みたので、その結果と得た Netlify での ISR についての知見をまとめました。対策の結果Contentful の Deploy Webhook は必要なくなり、 Netlify のビルドリソースの節約は達成しかし、問題として Netlify では ISR が行われない ことがわかった以上のように見た目はエラーなく動作はしていますが、思った ISR の挙動と違ったためそのことについても触れていきたいと思います。実装Netlify での ISR の挙動に触れる前に、このブログにおける実装についてです。Incremental Static Regeneration のとおり、 getStaticProps 内でrevalidate を指定します。僕は revalidate: 60 で設定しています、あまり短すぎるとサーバーへの過負荷の原因となる場合があるので適宜長さは調節してください。ブログのトップページのコンポーネントです。src/pages/index.tsxexport const getStaticProps = async () => { // Contentful から記事のデータを取得 const entries = await client.getEntries<Slug>(); if (entries != null) { // revalidate を設定 return { props: { entries }, revalidate: 1 }; } else throw new Error(); }; 続いてブログ記事のページとなるコンポーネントです。こちらでも同様に revalidate を指定しつつ、getStaticPaths の fallback を 'blocking' にします。src/pages/entry/[slug].tsx// -- 省略 -- export const getStaticProps = async () => { const entries = await client.getEntries<Slug>(); if (entries != null) { return { props: { entries }, revalidate: 1 }; } }; export const getStaticPaths = async () => { const entries = await client.getEntries<Slug>(); if (entries != null) { const paths = entries.items.map((item) => ({ params: { slug: item.fields.slug, }, })); // fallback は blocking に return { paths, fallback: 'blocking' }; } else throw new Error(); }; fallback: 'blocking' を指定するとエラーページを返さなくなるので、同時に関数コンポーネント内にエラーページを返す処理を記述しますsrc/pages/entry/[slug].tsxconst BlogPost: React.FC<Props> = (props) => { const { entries } = props; const router = useRouter(); const { slug } = router.query; const article = entries?.items.reduce((prev, cur) => { if (cur.fields.slug === slug) return cur; return prev; }); if (!article || !article?.sys.id) return <ErrorPage statusCode={404} message="not found" />; return ( <> <Head> <script async src="<https://platform.twitter.com/widgets.js>" charSet="utf-8" /> </Head> <Template {...article} metadata={((article as unknown) as { metadata: Metadata }).metadata} /> </> ); }; コード全体その他設定などNetlify 側の準備は特に必要はありません。 Next.js のデプロイの際、今までは next-on-netlify をインストールする必要があったのですが、Essentials Next.js として自動でインストールされるようになりました。Try the new Essential Next.js plugin, now with auto-install!ビルドコマンドもそのまま next build で、公開ディレクトリも out のままで大丈夫です。裏では Serverless として Lambda ベースの Netlify Functions に保存され、ISR という名の SSR のような処理が行われ始めます。Contentful の Deploy Webhook は必要なくなるので削除しましょう。これで Contentful から publish を行っても Netlify でビルドは走らず、前回のアクセスから1秒以上あとにブログへアクセスすると内容が更新されます。画面全体ごと...余談なぜ Netlify で ISR が出来ないのかNetlify における Next.js の ISR が完全に行われないことについてです。少し過去の記事にはなりますが Netlify のブログにおいて 、ISR は SSR と同じ処理が行われると明言されていました。For Incremental Static Regeneration, we currently server-side render those routes. We have some development in the works for caching those pages and including fallback pages, as well. When the additional functionality is added, you won’t have to make any code changes to see the benefits.Announcing one-click install Next.js Build Plugin on Netlifyまたこの仕様の明言と合わせて、ISR でページ全体を更新する理由について Netlify は2021年3月に公開された記事内で以下のように言及しています。Currently, ISR is built in to Next.js, and we serve those unbuilt pages via Netlify Functions, rendering them new every time, to avoid that caching problem. This isn’t the spirit of ISR, yes, but we are strongly in favor of atomic and immutable deploys. There are better ways to approach your sites than with this type of caching.Incremental Static Regeneration: Its Benefits and Its FlawsNetlify は ISR やそれのベースの手法である stale-while-revalidate のメリット・デメリットを挙げ、その上で Next.js で ISR を有効にしてもページ全体をレンダリングし直す対応を選んでいます。Netlify には Netlify の考えがあるように、結局 Vercel 以外で Next.js の ISR を行うのは現状難しそうだな...という感じがしています。Vercel 以外で ISR は行えるのかについて議論されている zenn の記事を見つけたので、気になる方は目を通してみてください。Vercel以外でNext.jsのISRをできるのか問題また Netlify について、後者(最新の方)の Netlify のブログにあるように、今後 ISR 周りでの対応(もしかすると ISR とは関係のない方法かもしれません)を新たに行うようなので動向に注目といったところです。おわりにContentful の Deploy Webhook を使わないことで、ビルド待ち時間の削減やリソースの節約に繋がり開発する側にとってはプラスになります。その一方で Next.js の機能はやはり Vercel でのホスティングを前提とするため、他のサービスでは期待したように動かないかもしれないということを考慮する必要がありました。僕はしばらく Netlify でこのブログをホスティングするつもりなので、もし Next.js + Netlify でページのレンダリングを動的に、1秒でも速くする方法知ってるよ!って方は教えていただけると嬉しいです。
  • ブログを始めた

    2021/03/24

    ブログを始めました。きっかけ今までは scrapbox に備忘録月報誰かに影響された記事としてだったり(スキルマップとか)その他雑念を思い立ったときに書いていました。最初は技術とかマメにタグ付けで分けていたんですが、段々と誰も見ないだろ〜って思いになってきて雑に書き捨てる、みたいないわゆる「チラシの裏」として使い始めたことを「あれ、これまずいんじゃないか...」と思い始めたのが主なきっかけです。誰にも見られてないと思って適当に文章を書く癖がつくと、日常会話での発言も適当になっていつか墓穴を掘りそう。という一種の恐怖が最近になって出てきました。なのでこうしてブログを作って周りに見え(やすくな)るようにしようと対策に打って出ています。改善後に期待ですね。後付けにはなるんですが他にブログを作る動機として、書くことは技術だけに限りたくないのでナレッジ共有サービスは使わない、あとはツイッターに長文を流したくない、で行き着いた先がブログ形式だったみたいなのもあります。あと数年前にも一度ブログを作ったんですが、その頃はかなりお粗末な静的サイトになってしまい更新する意欲が失せたのでそのリベンジ。did0es とかいう自分ですらどう読むのかもわからないハンドルネームの人間が、何を考えているのかをインターネットに残したい。そんな思いが { appearance: none } には込められています。技術などこれだけでもいろいろ書けそうなので、得た知見で使えそうなものは別記事に回したいと思います。以下ざっくり概観です。使ったものNext.jsContentfulNetlifySSG が出来てブログ記事を CMS で管理できる、 Wordpress が付いてこないものであれば何でもいいな〜と思いながら自分の好きなものを選んだ感じです。ホスティングが Netlify なのはドメインの移行が面倒だったから、というだけなのでそのうち Cloudflare Pages に移行するかもしれないです。てっきり S3 とかに上げるみたいに CI 上からデプロイをやるのかと思っていたら、ちゃんと管理画面らしきものがついていたのでこれは期待。Next.js を選んだ理由については僕の好みです。はじめは Gatsby 久々に使って組むかーと思っていたんですが、 かれこれ1年ぐらい触っていない(最後に触ったのが業務でブログ組む時で、納期がやばくてあまり知識を深められなかった...)という言い訳で、同じ React で SSG が出来る Next.js にやってきたという感じです。 Next.js 自体は TypeScript サポートが来たぐらいから触っていました。どんどん進化するので楽しみです、Vercel 一体何者なんだあとは Next.js の ISR も一部で試しました。ISR 自体の軽い説明なんですが、ユーザーからアクセスがあった場合あらかじめ生成したページを返し、指定した時間経過後はユーザーからアクセスがあった時に、キャッシュしていたページを返しつつデータ(このブログの場合だと記事)を取得。取得したデータだけ次回以降のアクセスでビルド&再レンダリングして返すといったようなものです。取得したデータだけをビルド&レンダリングするのがミソだと思います。詳しくは Next.js の docs や こちらの Qiita の記事 にわかりやすくまとまっていたのでご参考にどうぞ。これによってブログの記事を更新して publish したときに webhook でビルドする必要がなくなったので、 Netlify の上限を気にしてヒヤヒヤすることもなくなりました。やったね 結局 Netlify では ISR が想定どおりの挙動にならず、あまりやったねとはならないことがわかりました、こちらの記事にまとめていますただ、新しく記事を作った際に rss.xml が更新出来ない(デプロイ前にスクリプトで生成するため)課題が残っています。もういっそこのファイルだけ別のところに置いてしまおうかな...何か良い方法があれば教えて下さい。Contentful は初めて触りました。ブログ記事を溜め込むだけなので特に変わったことはしていません。ただのボヤキになるんですが、ブログ本文を Rich Text にしたらstrike-though や code block のパースはデフォルトでサポートされていないContentful のエディタが code block -> plane text の切り替えで日本語入力バグるウオオcontentfulのエディタcode block -> plane text切り替えるとバグるhttps://t.co/YVUTcxnBqA">https://t.co/YVUTcxnBqA— Shuta (@did0es) https://twitter.com/did0es/status/1375250540339159041?ref_src=twsrc^tfw">March 26, 2021ということで Long Text にしました。Long Text だと普通のマークダウンで書けるのでそこまで不自由はない、というよりも慣れたブログの執筆画面になったので良かったです。構成Contentful で記事を書いたら Netlify から配信するだけです。 前述したとおり ISR で記事の更新が反映されていくので、特に記事の内容周りの操作に関してはビルドの必要はありません。Markdown の React Component 化は react-markdown でやりました。ソースコードのハイライトは react-syntax-highlighter の Prism を使っています。一般的な Next.js でブログ組んだ!みたいな構成になんだかんだ落ち着いた印象です。長いものに巻かれていきます。その他について気になる方は こちら からご覧ください。おわりに結局書くことは技術の話題に偏りそうですが、以上よろしくお願いします。
About MeAbout Me
© 2025 did0es