Remix+CloudflareでWebサイトを作る47(Amazonアソシエイト、テストは`app/routes`にまとめ、ImagesとR2の料金比較、R2にアップ時にAVIFへ変換できなかった)

【2024-12-31】Amazonアソシエイトに登録
基本ここから入力していけばOKだった。
アソシエイトIDを任意のものにしたら入力した値-22
になったがこの22ってなんだ?変更したいな。
Amazon.co.jp が発行したアソシエイトIDを変更することはできません。アソシエイトIDは、当サイトに提出された加入申込書に記されたご希望の登録IDに基づいて自動的に作成されます。なお、アソシエイトIDが生成される際、末尾に自動的に-22が追加されます。
IDを変えたい場合は、既存のアソシエイトIDのもとにトラッキングIDを作成し、そのIDをご利用願います。(トラッキングID作成はこちら)
22は国の識別番号的なやつなんだろうか?
口座の情報も入力。
終わるとこの画像のようなページに遷移する。
右下の「税務情報(日本)の回答状況」が「未入力」になっているはずなのでここを入力して終わり。

tests/
ではなくapp/routes
に書きたい
【2024-12-31】Playwrightのテストファイルは背景
現在、*.spec.ts
ファイルがすべてtests/
以下にあるが、基本的にページごとにテストを書いてある。
するとapp/routes
以下と同じディレクトリ構造がtests/
にもまた作られてしまうが、これは冗長なので避けたい。
書く
ということで playwright.config.ts
を書き換えて、テストファイルを移動させる。
export default defineConfig({
- testDir: "./tests",
+ testMatch: "app/routes/**/*.spec.ts",
// testMatch: "./app/routes/**/*.spec.ts", // この書き方はしない!
testMatch: "./app/routes/**/*.spec.ts",
と書くとCIで実行時に以下のようなエラーが発生する。
Error: No tests found
ELIFECYCLE Test failed. See above for more details.
ここで作った tests/.auth/user.json
だけ適切な移動場所が無いのでここは一旦そのままで。

【2024-12-31】GitHub Actionsの残り使用可能時間の確認導線
背景
Freeプランだと1ヶ月に2000時間しかGitHub Actionsを使えないという制限がある。
テストコードが増えてきたのでどれくらい使用しているか確認したい。
導線
公式サイトを見るとこのURLからわかるとのこと。
月末だけど半分も使ってなかった。

【2024-01-01】Vitestでloader/actionのユニットテストをする
背景
Playwrightを使ってE2Eテストはできるようになった。
しかし、それではどうにもならない範囲も存在するのでVitestでテストをしたい。
具体的には「なにかしらのボタンをクリックしたときにDBの値がインクリメントされるだけで、UI上に反映されるわけではない」みたいなパターンに対応したい。
コード書く
1. パッケージインストール
pnpm add -D vitest jsdom
package.json
にテスト実行コマンドを追加。
--mode development
をつけることで .env.development
を参照してくれるようになる。
デフォルトは--mode test
。
{
"scripts": {
+ "vitest:development": "vitest --mode development",
vitest.config.ts
2. こんな感じ。
import * as path from "node:path";
import * as VitestConfig from "vitest/config";
export default VitestConfig.defineConfig({
test: {
globals: true,
environment: "jsdom",
include: ["**/*.test.?(c|m)[jt]s?(x)"],
},
define: {
"import.meta.vitest": false,
},
resolve: {
alias: {
"~": path.resolve(__dirname, "app"),
db: path.resolve(__dirname, "db"),
},
},
});
include: ["**/*.test.?(c|m)[jt]s?(x)"],
公式サイトを見るとデフォルトでは ['**/*.{test,spec}.?(c|m)[jt]s?(x)']
になっているが *.spec.ts
ファイルはPlaywrightで使っているため変更した。
ちょっと適当すぎる気もする。
db: path.resolve(__dirname, "db"),
以下のエラーに対応した。
Error: Failed to resolve import "db" from "app/routes/_index/hoge.server.ts". Does the file exist?
Plugin: vite:import-analysis
File: /appName/app/routes/_index/hoge.server.ts:1:19
1 | import { db } from "db";
| ^
3. tsconfig.ts
import.meta.env
を使うための変更。
--mode development
にしているので.env.development
にあるVITE_
はじまりのものを参照できるようになる。
"compilerOptions": {
"types": [
// vite.config.ts の `globals: true, ` に加えてここを設定することで各ファイルでimportする必要がなくなる
+ "vitest/globals",
// import.meta.env を使うために入れたがなくても動く
+ "vitest/importMeta",
],
その他
実装と同一ファイルにテストコードを書く、みたいな方法もあるらしい。
実装と同一のファイルにテストコードを記述するメリットとして以下のような点があります。
- private にしたい目的で export したくない関数をテストできる
- 実装とテストの距離が近いのでテストが書きやすい(私はテストコードを書くときだけいつもエディタの画面を分割して表示してます)
- さっとプロトタイプのコードを書くたいときに素早く書ける
loader/action はどうせexportするし、route.tsxと同じディレクトリにテストファイル書いているから同一ファイルに書きたいモチベーション特に無いけど必要になったらできることだけ頭の中に入れておく。
さいごに
PlaywrightとVitestを併用する形になったがdテストのカバレッジをあげながらどこはどっちで対応するかなど考えていきたい。

CI作って気づいたけどloader/actionに関することをテストするならサーバーを立ち上げる必要がある。Playwrightと違ってサーバーの立ち上げとテストの実行がうまく噛み合わない。
そもそもそういう使い方じゃなかった。

【2025-01-02】Cloudflare ImagesとR2の料金比較
料金体系
Cloudflare Images
- 格納
- 10万点あたり$5
- 配信
- 10万点あたり$1
- キャッシュされてもカウントされるっぽい
R2
- 格納
- $0.015 / GB・月(10GBまでは無料)
- 配信
- $4.50 / 100万リクエスト(100万リクエストまでは無料)
計算
以下の前提で計算する。
- 画像1枚が30KB
- 画像は100万枚保存する
- 1ヶ月に1000万配信
Cloudflare Images
- 格納
- $5/10万枚 * 10 = $50
- 配信
- $1/10万枚 * 10 = $10
- 合計
- $60
R2
- 格納
- 1枚30KBの場合
- 30KB * 100万枚 = 28.6GB
- 10GBまで無料なので = 18.6GB
- 18.6GB * $0.015 = $0.279
- 1枚100KBの場合
- 100KB * 100万枚 = 95.4GB
- 10GBまで無料なので = 85.4GB
- 85.4GB * $0.015 = $1.281
- つまり画像の大きさは誤差レベル
- 1枚30KBの場合
- 配信
- 1000万回 * $4.5 / 100万回 = $45
- ただし、100万回までは無料なので$4.5引いて $40.5
- 合計
- $0.279 + $40.5 = $40.779
結論
R2めっちゃ安い。
配信数が多くなればなるほど差がでかくなりそう。

【2025-01-04】R2にアップロードする画像をAVIFに変換したい(できなかった)
背景
Cloudflare ImagesはVariantsを使ってリサイズをしてAVIF形式に変換できる。
R2に保存する画像に対しても同じことをしたい。
AVIFへの変換方法
pnpm add sharp
+ import sharp from "sharp";
export async function action({ request, context }: ActionFunctionArgs) {
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
const lastResult = submission.reply();
const key = `contents/${crypto.randomUUID()}`;
const file = submission.value.file;
// ファイルをBufferとして読み取る
+ const arrayBuffer = await file.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
// AVIF形式に変換
+ const avifBuffer = await sharp(buffer).toFormat("avif").toBuffer();
+ const response = await context.cloudflare.env.BUCKET.put(key, avifBuffer);
const url = `${context.cloudflare.env.R2_DOMAIN}/${response?.key}`;
return successJson<R2UploadResponse>({ lastResult, data: { url } });
}
ビルド時にエラー発生
開発環境でremix vite:build && wrangler dev --remote
のremix vite:build
時に以下のエラーが発生した。
※ --remote
をつけるとリモートのR2に接続できるようになる
▲ [WARNING] The package "node:util" wasn't found on the file system but is built into node.
▲ [WARNING] The package "node:events" wasn't found on the file system but is built into node.
▲ [WARNING] The package "node:path" wasn't found on the file system but is built into node.
▲ [WARNING] The package "node:stream" wasn't found on the file system but is built into node.
▲ [WARNING] The package "node:child_process" wasn't found on the file system but is built into node.
▲ [WARNING] The package "node:crypto" wasn't found on the file system but is built into node.
▲ [WARNING] The package "node:os" wasn't found on the file system but is built into node.
Your Worker may throw errors at runtime unless you enable the "nodejs_compat" compatibility flag.
Refer to https://developers.cloudflare.com/workers/runtime-apis/nodejs/ for more details. Imported
from:
- node_modules/.pnpm/sharp@0.33.5/node_modules/sharp/lib/utility.js
✘ [ERROR] Build failed with 2 errors:
✘ [ERROR] Could not resolve "child_process"
node_modules/.pnpm/detect-libc@2.0.3/node_modules/detect-libc/lib/detect-libc.js:6:29:
6 │ const childProcess = require('child_process');
╵ ~~~~~~~~~~~~~~~
The package "child_process" wasn't found on the file system but is built into node.
- Add the "nodejs_compat" compatibility flag to your project.
✘ [ERROR] Could not resolve "fs"
node_modules/.pnpm/detect-libc@2.0.3/node_modules/detect-libc/lib/filesystem.js:6:19:
6 │ const fs = require('fs');
╵ ~~~~
The package "fs" wasn't found on the file system but is built into node.
- Add the "nodejs_compat" compatibility flag to your project.
これはsharp
をimportすると発生するエラーだけどどうにもならなさそう。

大量のWarningは以下を追加したらなくなった。
+ compatibility_flags = ["nodejs_compat"]

エラーはどうにもこうにも治らん。
↓ は同じように悩んでいるであろう人のScrap

wasm-vips
を使うと実行時間はsharpに比べてかなり遅いがどうにかなりそう。

【2025-01-05】wasm-vipsを使う
導入
pnpm add wasm-vips
コード
import { parseWithZod } from "@conform-to/zod";
import Vips from "wasm-vips";
import { z } from "zod";
const schema = z.object({
file: z
.custom<File>()
.transform((file) => file)
.refine((file) => file, {
message: "ファイルを選択してください",
})
.refine((file) => file.size < 500 * 1000, {
message: "ファイルサイズは最大5MBです",
})
.refine(
(file) => ["image/jpeg", "image/jpg", "image/png"].includes(file.type),
{
message: ".jpeg .jpgもしくは.pngのみ可能です",
},
),
});
export type R2UploadResponse = {
url: string;
};
export async function action({ request, context }: ActionFunctionArgs) {
const formData = await request.formData();
const submission = await parseWithZod(formData, { schema });
const lastResult = await submission.reply();
if (submission.status !== "success") {
return errorJson({ error: "入力した情報が正しくありません", lastResult });
}
try {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const hour = String(now.getHours()).padStart(2, "0");
const key = `contents/${year}${month}${day}/${crypto.randomUUID()}`;
const file = submission.value.file;
// AVIF形式に変換
const vips = await Vips();
const arrayBuffer = await file.arrayBuffer();
const image = vips.Image.newFromBuffer(await file.arrayBuffer());
const avifBuffer = image.writeToBuffer(".avif");
// R2にアップロード
const response = await context.cloudflare.env.BUCKET.put(key, avifBuffer);
const url = `${context.cloudflare.env.R2_DOMAIN}/${response?.key}`;
return successJson<R2UploadResponse>({ lastResult, data: { url } });
} catch (error: unknown) {
return errorJson({ error, lastResult });
}
}
エラー発生
remix vite:dev
では発生しないが、wrangler dev
で立ち上げ時に const vips = await Vips();
の部分で発生した。
TypeError: Cannot read properties of undefined (reading 'startsWith')
at null.<anonymous> (file:///AppName/node_modules/.pnpm/wasm-vips@0.0.11/node_modules/wasm-vips/lib/vips-es6.js:9:186)
at Cm (file:///AppName/build/server/index.js:247:14078)
at async Object.callRouteAction (file:///AppName/node_modules/.pnpm/@remix-run+server-runtime@2.15.0_typescript@5.7.2/node_modules/@remix-run/server-runtime/dist/data.js:36:16)
at null.<anonymous> (async file:///AppName/.wrangler/tmp/dev-AIKJbD/server.js:4392:23)
at async callLoaderOrAction (file:///AppName/node_modules/.pnpm/@remix-run+router@1.21.0/node_modules/@remix-run/router/router.ts:4963:16)
at async Promise.all (index 2)
クライアントサイドで変換しては?
してみたら同様にconst vips = await Vips();
で以下エラーが発生。
Uncaught (in promise) ReferenceError: SharedArrayBuffer is not defined
諦めてる記事を発見。
Issueもあるが自分の助けになる解決策はなかった。

C言語でパッケージ作ってパッケージ化したというお話。
サイズが5.3MB、圧縮後でも400KBとかなりでかめ。
Prismaの件でサイズがでかいものは避けなければという思いがある。