Cloudflare Workers 入門する

Cloudflare が、2022 年 05 月 09 日から「Welcome to Platform Week」と銘打って大量の発表が続いています。
「Cloudflare を使いこなせるようになる」ことに自分のリソースを傾けたいなぁと強く感じたので、手始めに Cloudflare Workers を触っていきます。

参考

環境準備 - 最初のデプロイ

毎度のごとく docker で環境準備。

1
2
3
4
5
6
7
8
FROM node:18-buster

RUN mkdir /usr/src/app
WORKDIR /usr/src/app

EXPOSE 8080
EXPOSE 8976
EXPOSE 8787
docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
version: "3"
services:
app:
build:
context: .
dockerfile: Dockerfile
privileged: true
command: tail -f /dev/null
ports:
- "8080:8080"
- "35729:35729"
- "8976:8976"
- "8787:8787"
volumes:
- .:/usr/src/app:cached
tty: true

以下コマンドで、準備。

1
2
3
4
5
$ docker compose up -d
$ docker compose exec app bash

# 以下コンテナ内 READMEにしたがって
echo "export default { fetch() { return new Response('hello world') } }" > index.js

この時点で、Cloudflare の管理画面側で、Cloudflare Workers 用のサブドメインを取った。

1
2
3
4
5
6
7
8
$ npx wrangler dev index.js
# 認証のためブラウザアクセスを要求するので対応する。

# READMEには書いていなかったが compatibility_date が設定された wrangler.toml が無いとエラー
echo "compatibility_date = \"2022-04-05\"" > wrangler.toml

$ npx wrangler publish index.js --name my-worker
# https://my-worker.<取得したサブドメイン>.workers.dev にアクセスすると hello world が返ってくる

とりあえずデプロイできた。

プロジェクト作成してみる

README にしたがって操作。

1
2
3
4
5
6
7
8
9
$ npx wrangler init my-worker

$ cd my-worker && npm run start

# 以下の質問が出てくるので任意で回答
# Would you like to use git to manage this Worker? (y/n)
# No package.json found. Would you like to create one? (y/n)
# Would you like to use TypeScript? (y/n)
# Would you like to create a Worker at my-worker/src/index.ts? (y/n) 今回は作成した。

一旦起動してますが、作成されているファイルを確認。

my-worker/src/index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* Welcome to Cloudflare Workers! This is your first worker.
*
* - Run `wrangler dev src/index.ts` in your terminal to start a development server
* - Open a browser tab at http://localhost:8787/ to see your worker in action
* - Run `wrangler publish src/index.ts --name my-worker` to publish your worker
*
* Learn more at https://developers.cloudflare.com/workers/
*/

export default {
async fetch(request: Request): Promise<Response> {
return new Response("Hello World!");
},
};

TypeScript を選択していたので型周りの記載が入っています。

wrangler.toml も作成されてますが、worker の名前やエントリポイント?になるファイルの指定など記載が入っています。

my-worker/wrangler.toml
1
2
3
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2022-05-14"

アクセスしてみるとこんな具合。

1
2
$  curl localhost:8787
Hello World!

デプロイしてみます。

1
2
npm run publish
# https://my-worker.<取得したサブドメイン>.workers.dev にアクセスすると hello world! が返ってくる

もっと掘る

サブのモジュール呼び出す

そういえば、拡張子って省略だったななどと思う。(最近圧倒的に Deno しか触ってない。)

my-worker/src/index.ts
1
2
3
4
5
6
7
import { currentTime } from "./sub";

export default {
async fetch(request: Request): Promise<Response> {
return new Response(`Hello World!:${currentTime()}`);
},
};
my-worker/src/sub.ts
1
2
3
4
5
export function currentTime(): string {
const date = new Date();

return date.toTimeString();
}

デプロイしてアクセスしてみると、次のような表示に。

1
Hello World!:16:20:13 GMT+0000 (Coordinated Universal Time)

Cloudflare 画面でクイック編集を開いてみると次のようになっています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/sub.ts
function currentTime() {
const date = new Date();
return date.toTimeString();
}

// src/index.ts
var src_default = {
async fetch(request) {
return new Response(`Hello World!:${currentTime()}`);
},
};
export { src_default as default };
//# sourceMappingURL=index.js.map

手元の node_modules ディレクトリを見てみると、rollup とか esbuild とかが入っています。
デプロイのタイミングでビルドして、送っているんでしょう。

試しに npm モジュールも使ってみます。
インストール。

1
$ npm install --save nanoid
my-worker/src/index.ts
1
2
3
4
5
6
7
8
import { currentTime } from "./sub";
import { nanoid } from "nanoid";

export default {
async fetch(request: Request): Promise<Response> {
return new Response(`Hello World!:${currentTime()}:${nanoid()}`);
},
};

そしてデプロイしてアクセス。

1
Hello World!:16:20:13 GMT+0000 (Coordinated Universal Time):zorptSj38To17ZNp8mR-V

nanoid がちゃんと動いています。

Cloudflare 画面でクイック編集を改めて開いてみると次のようになっています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// src/sub.ts
function currentTime() {
const date = new Date();
return date.toTimeString();
}

// node_modules/nanoid/index.browser.js
var nanoid = (size = 21) =>
crypto.getRandomValues(new Uint8Array(size)).reduce((id, byte) => {
byte &= 63;
if (byte < 36) {
id += byte.toString(36);
} else if (byte < 62) {
id += (byte - 26).toString(36).toUpperCase();
} else if (byte > 62) {
id += "-";
} else {
id += "_";
}
return id;
}, "");

// src/index.ts
var src_default = {
async fetch(request) {
return new Response(`Hello World!:${currentTime()}:${nanoid()}`);
},
};
export { src_default as default };
//# sourceMappingURL=index.js.map

なるほど、理解できた。
制限としてはこのビルド結果が、1MB までしか受け入れてくれないので、あまり大変なことはできないし、気を付ける必要がある。
(そんなことをするときは、おそらく Cloudflare Workers の範囲外なのだろう)

CRON 実行

WEB アクセス以外に、CRON で実行ができる様子なので、1 分に1回 Slack に投稿してくれるものを作ってみる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface Environment {
HOOK_URL: string;
}

export default {
async scheduled(
controller: ScheduledController,
environment: Environment,
ctx: ExecutionContext
): Promise<void> {
console.log(controller);
await fetch(environment.HOOK_URL, {
method: "POST",
body: JSON.stringify({ text: "AAAAAAAAAA" }),
});
},
};

export するメソッドを scheduled にする。
第一引数が微妙で、

今回は、controller を採用した。
node_modules/@cloudflare/workers-types/index.d.ts を見ると、同じ定義が 2 種あるので、極論どちらでも良さそう。

node_modules/@cloudflare/workers-types/index.d.ts
1
2
3
4
5
6
7
8
9
10
11
interface ScheduledController {
readonly scheduledTime: number;
readonly cron: string;
noRetry(): void;
}

declare abstract class ScheduledEvent extends ExtendableEvent {
readonly scheduledTime: number;
readonly cron: string;
noRetry(): void;
}

だが、次の記載に沿うなら controller が正しい模様。

node_modules/@cloudflare/workers-types/index.d.ts
1
2
3
4
5
declare type ExportedHandlerScheduledHandler<Env = unknown> = (
controller: ScheduledController,
env: Env,
ctx: ExecutionContext
) => void | Promise<void>;

1 分に 1 回通知させるため、以下の設定を hookURL と一緒に記載。

wrangler.toml
1
2
3
4
5
[triggers]
crons = ["* * * * *"]

[vars]
HOOK_URL = "https://hooks.slack.com/services/hogehogehoge...."

デプロイすると指定のチャンネルに通知を流してくれるようになりました。

直近で、Netlify Edge Functions Supabase Edge Functions を触っていたので、スケジュール実行機能があるのは面白さを感じます。
AWS Lambda でもできるしこれに対する対抗という気も感じます。
(これは、deno deploy にも欲しい)

Cloudflare Workers Sites やってみる

Cloudflare Workers Sites は、Cloudflare Workers を使って静的サイトをデプロイ/ホストしてもらう機能。
やってみます。

1
2
3
4
5
$ wrangler init my-site
#質問は適当に回答

$ cd my-site
$ npm i -D @cloudflare/kv-asset-handler

ドキュメントにある通りに、my-site/src/index.ts を以下の内容に書き換え。

my-site/src/index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { getAssetFromKV } from "@cloudflare/kv-asset-handler";

addEventListener("fetch", (event) => {
event.respondWith(handleEvent(event));
});

async function handleEvent(event) {
try {
// Add logic to decide whether to serve an asset or run your original Worker code
return await getAssetFromKV(event);
} catch (e) {
let pathname = new URL(event.request.url).pathname;
return new Response(`"${pathname}" not found`, {
status: 404,
statusText: "not found",
});
}
}

wrangler.toml にホストさせるファイルのパスを設定。

my-site/wrangler.toml
1
2
3
4
5
6
name = "my-site"
main = "src/index.ts"
compatibility_date = "2022-05-14"

[site]
bucket = "./public" # ここを追記

そして、my-site/public/index.html を適当に作成。

my-site/public/index.html
1
2
3
4
5
6
7
<!DOCTYPE html>
<html>
<head> </head>
<body>
<h1>MY SITE</h1>
</body>
</html>

で、デプロイしてアクセスしてみると、エラー
上手く KV が使えていない様子。
調べてみると、テンプレートのリポジトリを見つけた。

で見てみると、src/index.ts にあたるところの src/index.js の内容が全く異なっていたので、以下の内容に差し替え。

my-site/src/index.ts(書き換え)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import {
getAssetFromKV,
mapRequestToAsset,
} from "@cloudflare/kv-asset-handler";

/**
* The DEBUG flag will do two things that help during development:
* 1. we will skip caching on the edge, which makes it easier to
* debug.
* 2. we will return an error message on exception in your Response rather
* than the default 404.html page.
*/
const DEBUG = false;

addEventListener("fetch", (event) => {
event.respondWith(handleEvent(event));
});

async function handleEvent(event) {
let options = {};

/**
* You can add custom logic to how we fetch your assets
* by configuring the function `mapRequestToAsset`
*/
// options.mapRequestToAsset = handlePrefix(/^\/docs/)

try {
if (DEBUG) {
// customize caching
options.cacheControl = {
bypassCache: true,
};
}

const page = await getAssetFromKV(event, options);

// allow headers to be altered
const response = new Response(page.body, page);

response.headers.set("X-XSS-Protection", "1; mode=block");
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("Referrer-Policy", "unsafe-url");
response.headers.set("Feature-Policy", "none");

return response;
} catch (e) {
// if an error is thrown try to serve the asset at 404.html
if (!DEBUG) {
try {
let notFoundResponse = await getAssetFromKV(event, {
mapRequestToAsset: (req) =>
new Request(`${new URL(req.url).origin}/404.html`, req),
});

return new Response(notFoundResponse.body, {
...notFoundResponse,
status: 404,
});
} catch (e) {}
}

return new Response(e.message || e.toString(), { status: 500 });
}
}

/**
* Here's one example of how to modify a request to
* remove a specific prefix, in this case `/docs` from
* the url. This can be useful if you are deploying to a
* route on a zone, or if you only want your static content
* to exist at a specific path.
*/
function handlePrefix(prefix) {
return (request) => {
// compute the default (e.g. / -> index.html)
let defaultAssetKey = mapRequestToAsset(request);
let url = new URL(defaultAssetKey.url);

// strip the prefix from the path for lookup
url.pathname = url.pathname.replace(prefix, "/");

// inherit all other props from the default request
return new Request(url.toString(), defaultAssetKey);
};
}

ここまでやって再度デプロイ。
アクセスしてみると用意した index.html が返ってくるのがわかる。

公式のドキュメント通りで上手くいかないのは苦しい。

Cloudflare Workers Sites やってみる(リトライ)

ということで公式ドキュメント通りに操作してもリトライがかかってしまった。
もっと素直な環境準備はできないものだろうか?と考える。

やっぱりあった。
Cloudflare Docs - Workers - Quickstarts

これを見ると、以下の操作でいいらしい。

1
$ npm init cloudflare my-site2 https://github.com/cloudflare/worker-sites-template

が、コマンドがエラーになるのと、wrangler.toml は結局書き換えが必要。

my-site2/wrangler.toml
1
2
3
4
5
6
7
8
9
account_id = ""
name = "my-site"
type = "webpack"
workers_dev = true
site = { bucket = "./public" }

# 以下2行を追記
compatibility_date = "2022-05-14"
main = "workers-site/index.js"

デプロイしてアクセスしてみるとカワイイ蟹(Rust のあれ)が表示される。
ビルド結果と展開先ディレクトリを合わせて(今回は./public)、デプロイすれば展開できるのがわかる。
後は持っているドメインの割り当てなどあるが、あくまで確認なので今回はスキップ。

Remix を Cloudflare workers を動かしてみる

最近話題になる Remix も Cloudflare workers で動くそうなので、試してみる。

1
2
3
4
5
6
7
8
$ npx create-remix@latest
? Where would you like to create your app? remix-app
? What type of app do you want to create? Just the basics
? Where do you want to deploy? Choose Remix if you're unsure; it's easy to change deployment targets. Cloudflare Workers
? Do you want me to run `npm install`? Yes
? TypeScript or JavaScript? TypeScript

cd remix-app

remix-app/wrangler.toml がまた、これまでと様子が異なっているので、手直し。

remix-app/wrangler.toml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# name = "remix-cloudflare-workers"
name = "remix-app"
# type = "javascript"

# zone_id = ""
# account_id = ""
# route = ""
# workers_dev = true
main = "./build/index.js" # 追加
compatibility_date = "2022-04-05" # 追加

[site]
bucket = "./public"
# entry-point = "."

[build]
command = "npm run build" # この部分が書いてあると wrangler publish 時に指定のコマンドを実行してくれる

# [build.upload]
# format="service-worker"

デプロイしてアクセスすると、Welcome to Remix と表示されるページが作成されている。

この手順は、Developers IO - Remix を Cloudflare Workers で動かす最初の一歩 にほとんど同じ手順の記載があるのをこの辺りまで試みてから見つけた。

毎回 wrangler.toml を直しているが、これはテンプレートが v1 用になっているためらしいので、そのうち書き換えも要らなくなるんだろう。

SSR 自前でできるのでは?

ルーティングと、SSR 自前でできれば、Remix に頼る必要はなさそう。
Cloudflare Workers 向けのフレームワークHonoを見つけたので、使ってみます。

1
2
3
4
$ wrangler init my-site3
$ cd my-site3
$ npm install --save hono wrangler react react-dom
$ npm install --save-dev @types/react @types/react-dom

以下実装。

my-site3/src/index.ts
1
2
3
4
5
6
7
import { app } from "./hono_app";

export default {
fetch(request: Request, env: Env, event: FetchEvent) {
return app.fetch(request, env, event);
},
};
my-site3/src/hono-app.ts
1
2
3
4
5
6
7
8
import { Hono } from "hono";
import Index from "./page/index";

const app = new Hono();

app.get("/", (c) => c.html(Index()));

export { app };
my-site3/src/page/index.tsx
1
2
3
4
5
6
7
8
9
10
import ReactDOMServer from "react-dom/server";
import Template from "./template";

export default function Index() {
return ReactDOMServer.renderToString(
<Template>
<h1>Hono app!!</h1>
</Template>
);
}
my-site3/src/page/template.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React from "react";
export default function Template(props: { children: React.ReactNode }) {
return (
<html>
<head>
<title>test app</title>
</head>

<body>
<div>{props.children}</div>
</body>
</html>
);
}

IDE の型チェックでエラーが続くので、tsconfig を一か所修正。

my-site3/tsconfig.json
1
2
3
4
5
6
// いろいろ省略しているが以下を書き足す
{
"compilerOptions": {
"jsx": "react-jsx",
}
}

デプロイしてアクセスすると、Hono app!! を表示する HTML が返ってくる。
今回は/に対応するルートしか設定してないけど、Hono はドキュメント見た限りかなり多機能なのでこれもまたいろいろと試してみたいところ。


この辺で今回は打ち止めである。
今回のところ、Cloudflare Workers KV は Cloudflare Workers sites の時に暗黙的に利用をしただけで、意識して使えてはいない。
Durable Objectsも使えていない。
実装例パターンも多数あり、入り口がライトなのにものすごく深いというのが感想。
引き続き Cloudflare 関連でいろいろやってみたいところです。

ではでは。