wrangler で cron 実行の Cloudflare Workers を作る

Cloudflare Workers を使って Cloudflare Pages の古いデプロイを自動削除する』という記事を去年書いたが、この内容を wrangler で作り直す。

環境

  • wrangler: v3.0.0
    • compatibility_date: “2023-08-07”
  • vitest: v0.34.1
  • msw: v1.2.3

実装

Get started guideに従い、まず npm create cloudflare@latest を実行してひな形を作成する。
今回は TypeScript を使用する。デプロイはまだしない。

ひな形で作成された src/worker.ts はファイルごと削除する。

また『Cloudflare Workers を使って Cloudflare Pages の古いデプロイを自動削除する』という記事の「準備」を参考に、Cloudflare Pages の編集権限がある API トークンを作成する。
環境変数への設定は実行しなくていい。

wrangler の設定

環境変数については、秘匿すべき内容は .dev.vars、そうでない内容は wrangler.toml に定義する。
また cron トリガーもセットする。

.dev.vars

ACCOUND_ID と API_TOKEN を定義する。

ACCOUND_ID は削除対象の Cloudflare Pages があるアカウントのアカウント ID、API_TOKEN はCloudflare Pages の編集権限がある API トークン。

wrangler.toml

vars に PROJECT_NAMES を定義する。

PROJECT_NAMES は削除対象の Cloudflare Pages のプロジェクト名。
今回は削除対象とするプロジェクトが複数あるので配列で定義する。

また cron トリガーを指定する。指定内容は Supported cron expressions を参考にしてお好みで。

name = "delete-old-deploy"
main = "src/index.ts"
compatibility_date = "2023-08-07"
workers_dev = false

[vars]
PROJECT_NAMES=["xxxx", "yyyy"]

[triggers]
crons = ["8 1 3 * *"]

workers_dev = false は不要な route の割り当てをしないために設定する。

参考: https://developers.cloudflare.com/workers/configuration/routing/routes/#routes-with-workersdev

ソースコード

エントリーポイントは src/index.ts に書き、Cloudflare Pages へアクセスするロジックは src/pages.ts に書くことにする。

src/pages.ts

/**
 * @link https://developers.cloudflare.com/api/operations/pages-deployment-get-deployments
 */
interface Deployment {
	id: string;
	created_on: string;
}
export interface Deployments {
	result: Deployment[];
	success: boolean;
	errors: {
		code: number;
		message: string;
	}[];
}

const API_ROOT = 'https://api.cloudflare.com/client/v4/accounts';
const EXPIRATION_DAYS = 30;

export async function fetchDeployments({ accountId, projectName, token }: { accountId: string; projectName: string; token: string }) {
	const endPoint = `${API_ROOT}/${accountId}/pages/projects/${projectName}/deployments`;
	const response = await fetch(endPoint, {
		headers: {
			'Content-Type': 'application/json;charset=UTF-8',
			Authorization: `Bearer ${token}`,
		},
	});
	const deployments: Deployments = await response.json();

	if (!deployments.success) {
		throw new Error(`error: ${deployments.errors[0].message}`);
	}
	return deployments.result;
}

export async function deleteDeployment({
	deployment,
	accountId,
	projectName,
	token,
}: {
	deployment: Deployment;
	accountId: string;
	projectName: string;
	token: string;
}) {
	if ((Date.now() - new Date(deployment.created_on).getTime()) / 86400000 <= EXPIRATION_DAYS) {
		return;
	}

	await fetch(`${API_ROOT}/${accountId}/pages/projects/${projectName}/deployments/${deployment.id}`, {
		method: 'DELETE',
		headers: {
			'Content-Type': 'application/json;charset=UTF-8',
			Authorization: `Bearer ${token}`,
		},
	});
}

src/index.ts

import { deleteDeployment, fetchDeployments } from './pages';

export interface Env {
	ACCOUNT_ID: string;
	PROJECT_NAMES: string[];
	API_TOKEN: string;
}

export default {
	async scheduled(_: Request, env: Env): Promise<void> {
		await Promise.all(
			env.PROJECT_NAMES.map(async (projectName) => {
				const deployments = await fetchDeployments({
					accountId: env.ACCOUNT_ID,
					projectName: projectName,
					token: env.API_TOKEN,
				});
				await Promise.all(
					deployments
						.flat()
						.map((deployment) =>
							deleteDeployment({ deployment, accountId: env.ACCOUNT_ID, projectName: projectName, token: env.API_TOKEN }),
						),
				);
			}),
		);
	},
};

ローカル環境での実行

ひな形を作ったときの package.json には "start": "wrangler dev" というスクリプトが定義してあるはずだが、これは fetch の実行用なので "start": "wrangler dev --test-scheduled" に修正する。

Reference: https://developers.cloudflare.com/workers/wrangler/commands/#dev

npm run start で起動後、

curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*"

で cron を起動させられる。

今回の実装内容だとローカル環境で起動しても古いデプロイは削除されてしまうので注意。

テスト

cron 実行の wrangler のテストはできないと思われるので、ロジックだけ(src/pages.ts の内容だけ)テストを書く。

今回は Vitest と Mock Service Worker: MSW を使う。

リクエストのグローバルモック作成

jest における MSW の活用事例』という記事を参考に、削除 API 呼び出しについては vi.fn() を仕込んで呼び出し回数をテストしようと思うので、グローバルなモックとしては取得リクエストのみ定義する。

test/mocks/server.ts

import { rest } from 'msw';
import { setupServer } from 'msw/node';
import type { Deployments } from '../../src/pages';

export const deployments: Deployments = {
	result: [
		{
			id: 'dummy-1',
			created_on: '2021-04-09T00:00:00.000000Z',
		},
		{
			id: 'dummy-2',
			created_on: '2021-04-10T00:00:00.000000Z',
		},
		{
			id: 'dummy-3',
			created_on: '2021-04-11T00:00:00.000000Z',
		},
	],
	success: true,
	errors: [],
};

// This configures a Service Worker with the given request handlers.
export const server = setupServer(
	rest.get('https://api.cloudflare.com/client/v4/accounts/*/pages/projects/*/deployments', (_, res, ctx) => {
		return res(ctx.status(200), ctx.json(deployments));
	}),
);

test/setup.ts

import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './mocks/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());

vitest.config.ts

import { defineConfig } from 'vite';

export default defineConfig({
	test: {
		setupFiles: ['./test/setup.ts'],
	},
});

テストコードの実装

test/pages.test.ts に実装する。
削除 API のモックはテストコード内で vi.fn() を仕込んだものを追加している。

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchDeployments, deleteDeployment } from '../src/pages';
import { rest } from 'msw';
import { deployments, server } from './mocks/server';

describe('pages', () => {
	describe('fetchDeployments', () => {
		it('deployments を取得できる', async () => {
			const result = await fetchDeployments({
				accountId: 'test',
				projectName: 'test',
				token: 'test',
			});
			expect(result.length).toBe(3);
			expect(result[0].id).toBe('dummy-1');
			expect(result[1].id).toBe('dummy-2');
			expect(result[2].id).toBe('dummy-3');
		});
	});
	describe('deleteDeployment', () => {
		const mockFn = vi.fn();

		beforeEach(() => {
			vi.useFakeTimers();
			server.use(
				rest.delete('https://api.cloudflare.com/client/v4/accounts/*/pages/projects/*/deployments/*', (_, res, ctx) => {
					mockFn();
					return res(ctx.status(200));
				}),
			);
		});

		afterEach(() => {
			vi.useRealTimers();
		});

		it('30日より前のデプロイを削除できる', async () => {
			vi.setSystemTime(new Date(2021, 4, 11, 0, 0, 0));
			await Promise.all(
				deployments.result.map((deployment) => deleteDeployment({ deployment, accountId: 'test', projectName: 'test', token: 'test' })),
			);
			expect(mockFn).toHaveBeenCalledTimes(2);
		});
	});
});

テスト実行

package.json に "test": "vitest" というスクリプトを追加して

npm run test

でテスト実行する。

デプロイ

初回のみ環境変数をセットする。

npm wrangler login

でログインし、

npm wrangler secret put ACCOUNT_ID
npm wrangler secret put API_TOKEN

でそれぞれ環境変数をセットする。
初回の環境変数セット時点で Cloudflare Workers にアプリケーションが作成される。
アプリケーション名は wrangler.toml に指定した name になる。

環境変数セット後、

npm run deploy

でデプロイする。

注意点として、パッケージマネージャーを pnpm にしていた場合は pnpm deploy は pnpm 自体のコマンドと被るので実行できない。
package.json に定義されている "deploy": "wrangler deploy" というスクリプトの名前を適当に変更する必要がある。

デプロイ後

2023/09/03追記: wrangler.toml に workers_dev = false と書くとデフォルトの route を無効にできるようになっていた。

今回の実装内容は cron トリガーしか使っていないので Route は不要なのだが、現在の wrangler では Route を無効にすることができない。

参考: Support disabled routes in Wrangler configuration

よって、デプロイ後に手動で Route を無効にする必要がある。

(無効にしなくても害はない。でもどこかから攻撃目的と思われるリクエストが飛んできてエラーとして記録されるのが煩わしいため、無効にすることをおすすめする。)

以上