Astroでビルド時にレスポンシブ画像を生成しつつ、Cloudflare R2に置くようにする

Cloudflare R2に置く ←ここが主目的です。

Astroには「画像サービスAPI」と呼ばれる機能があり、この機能を用いて画像を変換したり、外部の画像変換エンドポイントへのURLを生成したりすることができます。
また、Astro 5からは実験的な機能としてレスポンシブ画像の生成がサポートされました。これは画像サービスAPIを利用して、ビルド時にレスポンシブ画像や外部エンドポイントへのURLを生成しておくというものです。

この記事では、ビルド時にローカルでレスポンシブ画像を生成し、生成した画像をCloudflare R2に置いて優勝していきます。

## 環境
- astro@5.1.2
- bun@1.1.43
- typescript
- Cloudflare Pages

## TL;DR
今回のコード全文です。
```astro.config.mjs
export default defineConfig({
  experimental: {
    responsiveImage: true,
  },
  image: {
    service: {
      // 開発時は標準の画像サービスAPIを使う
      entrypoint: process.env.CF_PAGES ? './src/lib/image/service' : 'astro/assets/services/sharp',
    },
    experimentalLayout: 'responsive',
    experimentalBreakpoints: [640, 750, 828, 1080, 1280, 1668, 2048, 2560],
  },
});
```
```service.ts
import type { ExternalImageService } from "astro";
import sharpService from "astro/assets/services/sharp";
import { s3 } from "bun";

const service: ExternalImageService = {
  validateOptions: sharpService.validateOptions,
  getHTMLAttributes: sharpService.getHTMLAttributes,
  getSrcSet: sharpService.getSrcSet,
  async getURL(options, imageConfig) {
    const transformedOptions = {
      ...options,
      src: typeof options.src === "string" ? options.src : new URL(".."+options.src.src, import.meta.url).href,
    };

    // 外部URLは処理しない
    if (
      isExternalURL(transformedOptions.src) &&
      !imageConfig.domains.includes(new URL(transformedOptions.src).hostname)
    ) {
      return transformedOptions.src;
    }

    const hash = getHash(transformedOptions);
    const filename = `${hash}.${transformedOptions.format || 'webp'}`;

    const file = s3(filename);
    // 生成済みの場合はそれを返す
    if (await file.exists()) {
      return import.meta.env.R2_ENDPOINT + filename;
    }

    const originalImage = await fetch(transformedOptions.src);
    const transformedImageBuffer = await sharpService.transform(new Uint8Array(await originalImage.arrayBuffer()), transformedOptions, imageConfig);
    await file.write(transformedImageBuffer.data);

    return import.meta.env.R2_ENDPOINT + filename;
  }
};

function getHash(options: any): string {
  const hash = Bun.hash(JSON.stringify(options));
  return hash.toString(16);
}

function isExternalURL(url: string) {
  return /^(?:http|ftp|https|ws):?\/\//.test(url) || url.startsWith('data:');
}

export default service;
```

## レスポンシブ画像の有効化
初めに、レスポンシブ画像の生成を有効化するために`astro.config.mjs`に以下を書き加えます。
（レスポンシブ画像は生成せず、R2にアップロードするだけであれば不要です）
```diff:astro.config.mjs
export default defineConfig({
+ experimental: {
+   responsiveImage: true,
+ },
+ image: {
+   experimentalLayout: 'responsive',
+   experimentalBreakpoints: [640, 750, 828, 1080, 1280, 1668, 2048, 2560],
+ },
});
```
注意点として、Astroではレスポンシブ画像のブレイクポイントとしてローカル処理向けと外部サービス向けがデフォルトで用意されており、外部サービス向けの方が5つほど種類が多くなっています。
今回R2に画像をアップロードするにあたって、外部サービスとして画像サービスAPIを実装するため、`experimentalBreakpoints`を明示的に指定しておかないと大量の画像が生成されてしまいます。（このコードではローカル処理向けのデフォルト値を指定しています）

## 画像サービスAPIの実装
前項でも触れましたが、画像サービスAPIには「ローカルサービス」と「外部サービス」の2種類が存在します。
ローカルサービスは外部のURLを結果として返すことができないため、今回は外部サービスとして実装していきます。

### 雛形
まずは適当なディレクトリにファイルを作成します。今回は`src/lib/image/service.ts`を作成しました。

続いて雛形を書きます。
Astro標準のローカルサービスである`sharpService`をベースにしているため、今回特に追加実装をしない部分はそのまま継承しています。
```service.ts
const service: ExternalImageService = {
  validateOptions: sharpService.validateOptions,
  getHTMLAttributes: sharpService.getHTMLAttributes,
  getSrcSet: sharpService.getSrcSet,
  async getURL(options, imageConfig) {
    // ここに実装していきます。
  },
};

export default service;
```

### 変換処理
今回は標準で用意されている`sharpService`を用いて画像を変換します。
```service.ts
  async getURL(options, imageConfig) {
    const transformedOptions = {
      ...options,
      src: typeof options.src === "string" ? options.src : new URL(".."+options.src.src, import.meta.url).href,
    };

    if (
      isExternalURL(transformedOptions.src) &&
      !imageConfig.domains.includes(new URL(transformedOptions.src).hostname)
    ) {
      return transformedOptions.src;
    }

    const originalImage = await fetch(transformedOptions.src);
    const transformedImageBuffer = await sharpService.transform(new Uint8Array(await originalImage.arrayBuffer()), transformedOptions, imageConfig);
  }
```
getURLの引数にある`options`は、ImageコンポーネントのPropsやgetImage関数の引数がそのまま渡されています。
したがって、ESModuleでインポートされた画像の場合は`options.src`が`ImageMetadata`となっているため、`transformedOptions`としてフラットにしてやります。また同時にそのファイルをfetch関数で取得できるようにURL化もしておきます。（ここもう少しうまくやる方法あると思うんですが、思いつかなかったので誰か教えてください…）

続いて、外部の画像を処理しないように早期リターンをしています。`isExternalURL`関数で外部のURLかどうかを判定し、`astro.config.mjs`の`image.domains`で指定されていないホスト名の場合はそのまま`src`を返しています。
```diff:service.ts
+ function isExternalURL(url: string) {
+   return /^(?:http|ftp|https|ws):?\/\//.test(url) || url.startsWith('data:');
+ }
```
最後に`src`をfetchし、`sharpService`のtransform関数で画像を変換しています。

### R2へのアップロード
Cloudflare WorkersにはR2のバインディングが存在しますが、バインディングはワーカーランタイム上でしか使用できないため、S3 APIを用いてアップロードを行います。
ちょうどよく最近Bunの標準機能にS3のサポートが追加されたので、今回はそれを使用しています。適宜ご自身の環境に読み替えてください。
```diff:service.ts
  async getURL(options, imageConfig) {
    const transformedOptions = {
      ...options,
      src: typeof options.src === "string" ? options.src : new URL(".."+options.src.src, import.meta.url).href,
    };

    if (
      isExternalURL(transformedOptions.src) &&
      !imageConfig.domains.includes(new URL(transformedOptions.src).hostname)
    ) {
      return transformedOptions.src;
    }

+   const hash = getHash(transformedOptions);
+   const filename = `${hash}.${transformedOptions.format || 'webp'}`;

+   const file = s3(filename);
+   if (await file.exists()) {
+     return import.meta.env.R2_ENDPOINT + filename;
+   }

    const originalImage = await fetch(transformedOptions.src);
    const transformedImageBuffer = await sharpService.transform(new Uint8Array(await originalImage.arrayBuffer()), transformedOptions, imageConfig);
+   await file.write(transformedImageBuffer.data);

+   return import.meta.env.R2_ENDPOINT + filename;
  }
```
今回は雑に`transformedOptions`をまるごとハッシュ化してユニークなファイル名としていますが、この辺りはご自身で適当に良い名前を付けてあげてください。

ビルド時間短縮のため、既に生成済みのファイルがある場合はそのファイルのURLを返すようにしています。
`R2_ENDPOINT`にはR2の公開エンドポイントを指定しておいてください。

## 画像サービスAPIの設定
実装が完了したら、`astro.config.mjs`に戻って実装した画像サービスAPIを設定します。
```diff:astro.config.mjs
image: {
+ service: {
+   entrypoint: process.env.CF_PAGES ? './src/lib/image/service' : 'astro/assets/services/sharp',
+ },
  experimentalLayout: 'responsive',
  experimentalBreakpoints: [640, 750, 828, 1080, 1280, 1668, 2048, 2560],
},
```
今回は`src/lib/image/service.ts`に画像サービスAPIを実装したため、そのファイルパスを指定しています。
常に今回実装した画像サービスAPIを使用するようにすると、開発モードでも画像の変換とR2へのアップロードが行われてしまうため、環境変数によって標準の`sharpService`と切り替えるようにしています。Cloudflare Pagesではデフォルトで`CF_PAGES = 1`が設定されているため、今回はそれで切り替えています。

## 終わり
以上でビルド時にレスポンシブ画像が生成され、R2に配置されるようになります。
余談ですが、私の環境では一部の画像をDirectusというCMSから取得していて、画像サービスAPIの中でDirectusの画像はDirectusの画像変換機能で変換して取得、R2へアップロードするようにしています。

他にも工夫次第でいろいろなことができるので、試してみてはいかがでしょうか。

https://docs.astro.build/ja/reference/image-service-reference/