🐈

Hono と Cloudflare Images で Next.js の画像リサイズを再現する

2024/12/05に公開

Hono Advent Calendar 2024 4 日目の記事です。遅れてすみません!!!!!!!!!!!!!!!!!!!!!
去年はかけなかったけど今年は書けたのでよかったです..........

TL;DR

Cloudflare Images を使えば、Next.js の next/image による画像最適化を再現できます。さらに、Cloudflare Images の「blur」オプションを利用することで、base64 プレースホルダー画像をサーバー側で生成する必要がなくなり、処理が効率化されます。コスト面でも、Vercel の画像最適化機能($5/1000 枚[1])に比べ、Cloudflare Images は大幅に安価($0.5/1000 枚[2])です。

デモ

https://next-demo.napochaan.dev/

https://github.com/napolab/cloudflare-next-image-demo

next image と Cloudflare Image による画像最適化

はじめに

この記事は @cloudflare/next-on-pages@opennextjs/cloudflare 環境でも next/image を使いたくて調査した結果の記事です。結果として Vercel に hosting されている Next.js にも適用できることが分かりました。

Cloudflare Images で cdn-cgi を利用する方法は紹介されています(Integrate with frameworks)が、このやり方だと _next/image には搭載されている Accept Header による format の自動選択ができない[3]ので自前実装を選択しています。最適化サーバーを作って custom loader を使うことも可能ですが、_next/image を再現したかったので使っていません。

Cloudflare Workers にデプロイされた next/image の課題

Cloudflare Workers を利用して Next.js をデプロイするには、以下の 2 つの選択肢があります。

  • @cloudflare/next-on-pages
  • @opennextjs/cloudflare

しかし、これらのライブラリでは画像最適化機能が未実装のため、_next/image を再現するには自前でサーバーを構築する必要があります。

一見デプロイすると画像が表示されて動いているように見えますが、これらのライブラリは /_next/image で画像が返ってくるように実装してあるでけです。

@opennextjs/cloudflare

_next/image を受けて host されている画像だったら env.ASSETS から、host されていなければ外部に fetch をしているだけ
https://github.com/opennextjs/opennextjs-cloudflare/blob/8502d6298889f2916f38b702e49cb90073166e07/packages/cloudflare/src/cli/templates/worker.ts#L57-L64

@cloudflare/next-on-pages

画像最適化サーバーの構築手順

以下の 3 ステップで _next/image を再現します。

  1. リクエストのインターセプト: Cloudflare Workers の Routes 機能を利用
  2. パラメータの取得とバリデーション: Hono と Zod を活用
  3. 画像の最適化: Cloudflare Images の cf オブジェクトを使用

Routes を設定しリクエストをインターセプト

Cloudflare Workers の Routes 機能[4]を利用して _next/image へのリクエストをインターセプトします。この設定を行うには、wrangler.toml に以下を追加します。

routes = [
  { pattern = "zzz.xxx.yyy/_next/*", zone_name = "xxx.yyy" },
]

ただし、Cloudflare Images がリクエストループを引き起こす可能性があります。そのため、via ヘッダー[5]を確認して無限ループを防ぐミドルウェアを作成します。

index.ts
import { Hono } from "hono";
import { createFactory, createMiddleware } from "hono/factory";

const preventResizeLoop = createMiddleware((c, next) => {
  const via = c.req.header("via") ?? "";
  // 'via' ヘッダーに 'image-resizing' が含まれる場合、無限ループを防ぐためにリクエストをそのまま返す
  if (/image-resizing/.test(via)) {
    return fetch(c.req.raw);
  }

  return next();
});

const factory = createFactory();
const app = factory.createApp();

const nextImage = factory.createHandlers(preventResizeLoop, async (c) => {
  // URL Parameter から URL と画像の最適化オプション取得
  // 画像最適化
});

app.get("/_next/image", nextImage);
app.get("*", (c) => fetch(c.req.raw));

export default app;

パラメータの取得とバリデーション

Hono の @hono/zod-validator を利用し、URL パラメータのバリデーションを行います。この方法では、型安全性を保ちながら簡単に入力値の検証が可能です。以下は、画像最適化用のオプションを定義した Zod スキーマの例です。

schema.ts
import { z } from "zod";

const Format = z.union([z.literal("avif"), z.literal("webp"), z.literal("jpeg"), z.literal("png")]);

export const TransformOptions = z.object({
  blur: z.coerce.number().min(0).max(250).optional(),

  format: Format.optional(),

  height: z.coerce.number().min(0).optional(),
  h: z.coerce.number().min(0).optional(),

  width: z.coerce.number().min(0).optional(),
  w: z.coerce.number().min(0).optional(),

  quality: z.coerce.number().min(0).max(100).optional(),
  q: z.coerce.number().min(0).max(100).optional(),
});
index.ts
import { Hono } from "hono";
import { createFactory, createMiddleware } from "hono/factory";
import { zValidator } from "@hono/zod-validator";

import { TransformOptions } from "./schema";

const preventResizeLoop = createMiddleware((c, next) => {
  const via = c.req.header("via") ?? "";
  if (/image-resizing/.test(via)) {
    return fetch(c.req.raw);
  }

  return next();
});

const factory = createFactory();
const app = factory.createApp();

const nextImage = factory.createHandlers(preventResizeLoop, zValidator("query", TransformOptions), async (c) => {
  // 画像最適化
});

app.get("/_next/image", nextImage);
app.get("*", (c) => fetch(c.req.raw));

export default app;

Cloudflare Images を使用した最適化

まず https://dash.cloudflare.com/<account_id>/images/delivery-zones で画像変換を使用する zone を選択します。

Transformations

Cloudflare Images の cf オブジェクトを利用して画像を最適化します。

他にも Cloudflare Images の変換オプションはたくさんあるので、詳しくはドキュメントを参照してください。
https://developers.cloudflare.com/images/transform-images/transform-via-workers/

以下は、Accept Header に応じたフォーマットの選択を含む実装例です。

index.ts
import { Hono } from "hono";
import { createFactory, createMiddleware } from "hono/factory";
import { zValidator } from "@hono/zod-validator";

import { TransformOptions } from "./schema";

const preventResizeLoop = createMiddleware((c, next) => {
  const via = c.req.header("via") ?? "";
  if (/image-resizing/.test(via)) {
    return fetch(c.req.raw);
  }

  return next();
});

const factory = createFactory();
const app = factory.createApp();

const nextImage = factory.createHandlers(
  preventResizeLoop,
  zValidator("query", TransformOptions),
  async (c) => {
    const query = c.req.valid("query");
    const accept = c.req.header("accept") ?? "";

    return fetch(c.req.raw, {
      cf: {
        image: {
          ...query,
          get quality() {
            return query.quality ?? query.q;
          },
          get width() {
            return query.width ?? query.w;
          },
          get height() {
            return query.height ?? query.h;
          },
          get format() {
            if (query.format !== undefined) {
              return query.format;
            }

            if (/image\/avif/.test(accept)) {
              return "avif";
            } else if (/image\/webp/.test(accept)) {
              return "webp";
            }

            return undefined;
          },
        },
      },
    });
  }
);

app.get("/_next/image", nextImage);
app.get("*", (c) => fetch(c.req.raw));

export default app;

blur placeholder の導入

画像最適化サーバーが完成したら、blur placeholder を導入して UX を向上させます。

通常、Next.js の next/image では静的画像に対してビルド時に blur プレースホルダーが生成されますが、動的な画像では同じ機能を利用できません。

Cloudflare Workers で base64 blur プレースホルダーの生成は可能ですが、SSR が著しく遅くなる問題[6] が発生したため、Cloudflare Images の「blur」オプションを採用しました。

以下は、blur placeholder を実現するカスタムコンポーネントの例です。

Next.js の placeholderStyle を計算しているところを参考に実装
Image.tsx
"use client";
import NextImage from "next/image";

import {
  useCallback,
  useMemo,
  useState,
  type ComponentPropsWithoutRef,
} from "react";

type Props = ComponentPropsWithoutRef<typeof NextImage>;

const Image = (props: Props) => {
  const [loading, setLoading] = useState(true);
  const handleLoad = useCallback(
    (event: React.SyntheticEvent<HTMLImageElement, Event>) => {
      setLoading(false);
      props?.onLoad?.(event);
    },
    [props],
  );
  const isPlaceholderBlur =
    typeof props.blurDataURL === "string" && props.placeholder === "blur";

  const placeholderStyle = useMemo(
    () =>
      typeof props.blurDataURL === "string" && props.placeholder === "blur"
        ? {
            ...props.style,
            backgroundSize: props.style?.objectFit ?? "cover",
            backgroundPosition: props.style?.objectPosition ?? "50% 50%",
            backgroundRepeat: "no-repeat",
            backgroundImage: `url("${props.blurDataURL}")`,
          }
        : {},
    [props.blurDataURL, props.placeholder, props.style],
  );

  return (
    <NextImage
      {...props}
      onLoad={handleLoad}
      alt={props.alt}
      placeholder={isPlaceholderBlur ? "empty" : props.placeholder}
      style={isPlaceholderBlur && loading ? placeholderStyle : props.style}
    />
  );
};

export default Image;

ヘルパー関数として小さい blur 画像の URL を生成する関数を作ります。

helper.ts
type BlurOptions = {
  width?: number;
  quality?: number;
  blur?: number;
};

export const formatBlurURL = (
  path: string,
  blurOptions?: BlurOptions,
): string => {
  const searchParams = new URLSearchParams();
  searchParams.set("url", path);
  searchParams.set("w", `${blurOptions?.width ?? 32}`);
  searchParams.set("q", `${blurOptions?.quality ?? 30}`);
  if (blurOptions?.blur !== undefined) {
    searchParams.set("blur", `${blurOptions.blur}`);
  }

  return `/_next/image?${searchParams.toString()}`;
};

あとはこのように使うだけです

https://github.com/napolab/cloudflare-next-image-demo/blob/main/projects/web/src/app/page.tsx#L38-L50

next image と Cloudflare Image による画像最適化

まとめ

Cloudflare Images を使って Next.js の画像最適化を再現できました。まだまだ課題のある Cloudflare 環境の Next.js 運用ですが、少しずつ開拓していきたいです。
Cloudflare Images の低コストと高機能性を活用すれば、Next.js に限らず、他のアプリケーションでも画像周りの運用効率を向上させる選択肢となり得ます。

おまけ1: Vercel 環境への適用例」

https://www.studiognu.org/ja/lp/kanata

Vercel にデプロイした Next.js のアプリケーションには一切変更を加えずに Cloudflare Images の適用をしてみた。
料金と加えて考えても Next.js での画像最適化は Cloudflare Images に任せるのが良いかもしれないです。

Routes の設定

ダッシュボード経由で設定した

Cloudflare Workers の Routes 設定

画像最適化の結果

platform width quality format size
Vercel 400 30 png 65524 Byte
Vercel 400 30 webp 33098 Byte
Cloudflare Images 400 30 png 75688 Byte
Cloudflare Images 400 30 webp 27434 Byte
Vercel による Image Optimization
>>>  curl 'https://www.studiognu.org/_next/image?url=%2Fkanata.png&w=400&q=30' -H "Accept: image/png" -I

HTTP/2 200
date: Thu, 05 Dec 2024 11:22:51 GMT
content-type: image/png
content-length: 65524
access-control-allow-origin: *
age: 0
cache-control: public, max-age=0, must-revalidate
content-disposition: inline; filename="kanata.png"
content-security-policy: script-src 'none'; frame-src 'none'; sandbox;
last-modified: Fri, 21 Jun 2024 17:33:25 GMT
strict-transport-security: max-age=63072000
vary: Accept
x-matched-path: /kanata.png
x-vercel-cache: HIT
x-vercel-id: hnd1::gms8b-1733397771331-dd6b0740acf2
cf-cache-status: DYNAMIC
report-to: {"endpoints":[{"url":"https:\/\/fanyv88.com:443\/https\/a.nel.cloudflare.com\/report\/v4?s=W2Yq1Uqjn4Fhr6oRUnrR4sHx2I7qtypcliAg3vXDO54BOSNa%2FCh6cMYnSQosEBQJDcg23tQIvwXhoCqGdznv2Nwd9eajPiPTyTdfOOjSrxDc%2BPD%2Bmw0QE1iXJZlbH7jrGRcQvg%3D%3D"}],"group":"cf-nel","max_age":604800}
nel: {"success_fraction":0,"report_to":"cf-nel","max_age":604800}
server: cloudflare
cf-ray: 8ed3a9269f73e394-NRT
alt-svc: h3=":443"; ma=86400
server-timing: cfL4;desc="?proto=TCP&rtt=4991&min_rtt=3827&rtt_var=2076&sent=7&recv=8&lost=0&retrans=0&sent_bytes=3394&recv_bytes=819&delivery_rate=1103736&cwnd=177&unsent_bytes=0&cid=1cfc45f2529dfb74&ts=55&x=0"
>>> curl 'https://www.studiognu.org/_next/image?url=%2Fkanata.png&w=400&q=30' -H "Accept: image/webp" -I
HTTP/2 200
date: Thu, 05 Dec 2024 11:22:57 GMT
content-type: image/webp
content-length: 33098
access-control-allow-origin: *
age: 0
cache-control: public, max-age=0, must-revalidate
content-disposition: inline; filename="kanata.webp"
content-security-policy: script-src 'none'; frame-src 'none'; sandbox;
last-modified: Fri, 21 Jun 2024 17:33:25 GMT
strict-transport-security: max-age=63072000
vary: Accept
x-matched-path: /kanata.png
x-vercel-cache: MISS
x-vercel-id: hnd1::dhbtq-1733397776896-2e865601e77f
cf-cache-status: DYNAMIC
report-to: {"endpoints":[{"url":"https:\/\/fanyv88.com:443\/https\/a.nel.cloudflare.com\/report\/v4?s=NpCN5G7tNqGc3QivkwlFzcAQpO75Zo3kiSs97jqSuP7VnC5P7ZVHhItteOA5dJxpYx1zOT%2BarQWF%2BRwncVWYFJdJVBfSLR%2Fr%2Fe%2BdpWqL8z2jmPjGh2wVotY5QB7A154%2FFA0CzA%3D%3D"}],"group":"cf-nel","max_age":604800}
nel: {"success_fraction":0,"report_to":"cf-nel","max_age":604800}
server: cloudflare
cf-ray: 8ed3a9497d7b348d-NRT
alt-svc: h3=":443"; ma=86400
server-timing: cfL4;desc="?proto=TCP&rtt=19419&min_rtt=7640&rtt_var=9820&sent=7&recv=8&lost=0&retrans=0&sent_bytes=3393&recv_bytes=820&delivery_rate=552879&cwnd=247&unsent_bytes=0&cid=a52e75ca6be0bf0a&ts=326&x=0"
Cloudflare Images による Image Optimization
>>> curl 'https://www.studiognu.org/_next/image?url=%2Fkanata.png&w=400&q=30' -H "Accept: image/png" -I

HTTP/2 200
date: Thu, 05 Dec 2024 11:23:14 GMT
content-type: image/png
content-length: 75688
cf-ray: 8ed3a9b78bb4e072-NRT
cf-cache-status: MISS
accept-ranges: bytes
access-control-allow-origin: *
cache-control: public, max-age=0, must-revalidate
etag: "cf1wbUYzJ1KKO8dw3Xco_oCqgQd5ed1YgdJ4KTJw3oDw:9d8961aa36a0bae171834e5d8fb4322f"
last-modified: Sat, 16 Nov 2024 01:57:41 GMT
strict-transport-security: max-age=63072000
vary: Accept
cf-bgj: imgq:69,h2pri
cf-resized: internal=ok/r q=0 n=62+59 c=11+48 v=2024.10.6 l=75688 f=false
content-security-policy: default-src 'none'; navigate-to 'none'; form-action 'none'
priority: u=4;i=?0,cf-chb=(37;u=2;i=?0 292;u=5;i=?0)
warning: cf-images 299 "low quality is not recommended"
warning: cf-images 299 "cache-control is too restrictive"
x-content-type-options: nosniff
report-to: {"endpoints":[{"url":"https:\/\/fanyv88.com:443\/https\/a.nel.cloudflare.com\/report\/v4?s=hhVDELbo78Y2ReSBbiIhIeZcxBoximjaeounDHYKeyjxT%2FKWyUqv%2BC7Er%2FroculDCFH6iLMLG9oPjvgXR8f32Px2gzqBnkCRP6PXMhh5v0nTSSmwVyRGL91h31csed1CTu5wtg%3D%3D"}],"group":"cf-nel","max_age":604800}
nel: {"success_fraction":0,"report_to":"cf-nel","max_age":604800}
server: cloudflare
alt-svc: h3=":443"; ma=86400
server-timing: cfL4;desc="?proto=TCP&rtt=7040&min_rtt=6015&rtt_var=2294&sent=7&recv=9&lost=0&retrans=0&sent_bytes=3394&recv_bytes=819&delivery_rate=701078&cwnd=185&unsent_bytes=0&cid=b3f18d70ea90992f&ts=373&x=0"
>>>  curl 'https://www.studiognu.org/_next/image?url=%2Fkanata.png&w=400&q=30' -H "Accept: image/webp" -I

HTTP/2 200
date: Thu, 05 Dec 2024 11:23:22 GMT
content-type: image/webp
content-length: 27434
cf-ray: 8ed3a9e8d8fa80bf-NRT
cf-cache-status: MISS
accept-ranges: bytes
access-control-allow-origin: *
cache-control: public, max-age=0, must-revalidate
etag: "cf1wbUYzJ1KKO8dw3Xco_oCqgQU-vbagdrlkVdn7cADw:9d8961aa36a0bae171834e5d8fb4322f"
last-modified: Sat, 16 Nov 2024 01:57:41 GMT
strict-transport-security: max-age=63072000
vary: Accept
cf-bgj: imgq:31,h2pri
cf-resized: internal=ram/r q=0 n=0+78 c=11+66 v=2024.10.6 l=27434 f=false
content-security-policy: default-src 'none'; navigate-to 'none'; form-action 'none'
warning: cf-images 299 "cache-control is too restrictive"
x-content-type-options: nosniff
report-to: {"endpoints":[{"url":"https:\/\/fanyv88.com:443\/https\/a.nel.cloudflare.com\/report\/v4?s=R3LFpaShse25LWdo9iFpXILPOOWNX%2BNv7mY7KjZEGRphO5%2F6A03nkiGBUufWRznAhadRHc1%2Fd3fb3n%2BDg8KmlJRRT2JGiT19Eva5pXyKy20hm8xglSHZZRQQTx0H6da%2BhuslCQ%3D%3D"}],"group":"cf-nel","max_age":604800}
nel: {"success_fraction":0,"report_to":"cf-nel","max_age":604800}
server: cloudflare
alt-svc: h3=":443"; ma=86400
server-timing: cfL4;desc="?proto=TCP&rtt=4811&min_rtt=3908&rtt_var=1713&sent=7&recv=9&lost=0&retrans=0&sent_bytes=3394&recv_bytes=820&delivery_rate=1042448&cwnd=230&unsent_bytes=0&cid=7dfd96c34c5cdec7&ts=307&x=0"

おまけ2: Hono の getPath を活用した複数ドメイン対応

URL Parameter を用いて画像最適化を行えるサーバーを作ったのでこれを複数の domain に対して使いたいとなったときにその都度 Worker を作るのは面倒です。
Routes は 1 つの Worker にたいして複数設定することができるので domain と pathname ごとに処理を分ければ 1 つの Worker で複数の domain に対して画像最適化を行うことができます。これは Hono の getPath を使うことで実現できます。

https://hono.dev/docs/api/routing#routing-with-hostname

以下の website は 1 つの Worker で複数の domain に対して画像最適化を行っています。

wrangler.toml
name = "optimizaan"
compatibility_date = "2023-12-01"
main="src/index.ts"
workers_dev = false

routes = [
  { pattern = "www.flatkobo.com/_next/*", zone_name = "flatkobo.com" },
  { pattern = "www.flatkobo.com/images/*", zone_name = "flatkobo.com" },

  { pattern = "lp.flatkobo.com/events/kobosai-2023/img/*", zone_name = "flatkobo.com" },
  { pattern = "lp.flatkobo.com/_next/*", zone_name = "flatkobo.com" },
  { pattern = "lp.flatkobo.com/images/*", zone_name = "flatkobo.com" },
]

index.ts
import { Hono } from "hono";

import { transformImageHandler as transformRawImageHandler } from "./optimize-images";
import { transformImageHandler as transformURLImageHandler } from "./optimize-images/with-url";


const app = new Hono<Env>({
  getPath: (req) => req.url.replace(/^https?:\/([^?]+).*$/, "$1"),
});

app.get("/www.flatkobo.com/_next/image", ...transformURLImageHandler);
app.get(
  "/www.flatkobo.com/_next/static/media/*.:ext{jpg|jpeg|webp|png|avif|gif}",
  ...transformRawImageHandler,
);
app.get("/www.flatkobo.com/images/*", ...transformRawImageHandler);

app.get("/lp.flatkobo.com/_next/image", ...transformURLImageHandler);
app.get(
  "/lp.flatkobo.com/_next/static/media/*.:ext{jpg|jpeg|webp|png|avif|gif}",
  ...transformRawImageHandler,
);
app.get("/lp.flatkobo.com/images/*", ...transformRawImageHandler);
app.get(
  "/lp.flatkobo.com/events/kobosai-2023/img/*",
  ...transformRawImageHandler,
);

app.get("*", (c) => fetch(c.req.raw));

export default app;
脚注
  1. https://vercel.com/docs/image-optimization/limits-and-pricing#pro-and-enterprise ↩︎

  2. https://developers.cloudflare.com/images/pricing/#images-transformed ↩︎

  3. https://developers.cloudflare.com/images/transform-images/transform-via-url/#format ↩︎

  4. https://developers.cloudflare.com/workers/configuration/routing/routes/ ↩︎

  5. https://developers.cloudflare.com/images/transform-images/transform-via-workers/#prevent-request-loops ↩︎

  6. https://zenn.dev/link/comments/c4848223b7b009 ↩︎

Discussion