Web Speed Hackathon 2024でやったこと(作問者視点)

目次

    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/webcomponentsjs
    • regenerator-runtime
    • lodash
    • underscore
    • es5-shim, es6-shim, es7-shim
    • unorm
    • immutable
    • moment-timezone
    • three

    Canvas芸(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;
    }`;

    (GPTにかなり書いてもらって加筆したので、本当にあっているのかこれ…?とは思っています。ご意見ください)

    E2E・VRTの一部

    主に VRT を書いていました。Playwright で E2E すると良いぞとのお告げを各所で聞いていたので、今回触れることができてよかったです。ただ、 waitFor が要素の width, height を見ていることで、何回かハマったケースがありました。意外と E2E って書くの難しいですね。

    おわりに

    実装の振り返りとしては、まず第一に、無料で Docker イメージを放り投げて動かしてくれるサービスは皆偉大と思いました。クラウドサービスを提供している部署にいるのもあって、無料で提供することの難しさと、そのありがたみを実感しています。

    また、WSH を通して Node.js と TypeScript を用いたバックエンドの実装にガッツリ関われたのがよかったです。日頃は Web フロントエンドを軸に活動しているので、今回の実装を通して視座が上がりました。

    あとは Canvas 芸を仕込むことができたのもよかったです。昔から Web で 3D をこねくり回すのが好きだったので、これを何かしらの成果物に変えられて満足しています。

    まだWSH2024の問題を解かれていない方は、ぜひ以下のレポジトリから取り組んでみてください。docsに計測用のLighthouseの値の傾斜なども記載されているので、お手元でWSHができるようになっています。また、解説動画も併せてご覧ください。

    WSHは、2021に学生版に参加してまぐれで2位を取った以来は特に泣かず飛ばずで、今回作問の話が来た際はかなり驚きました。弊社は本当にやりたいやりたいと言っていると、やらせてもらえる環境でよかったです。

    ちなみに、WSH 2024が始まる直前にずっと受け取りそこねていたWSH 2021準優勝のトロフィーを渡されてウケました。良い思い出になりました。

    同じような思い出を得たい方はサイバーエージェントにいらしてください。We are hiring!

    この記事を共有する