🗂

Cloudflare Workers + Node.jsでマークダウン変換APIを動かす

2021/02/07に公開
2

Cloudflare WorkersでNode.jsを動かしてみたので、その手順を整理してまとめておきます(元のスクラップ)。例として、Node.js + markedでHTMLをマークダウンへと変換するAPIをCloudflare Workersにデプロイしてみます。

関連記事

https://zenn.dev/catnose99/articles/d1d16e11e7c6d0

事前準備

Cloudflareのアカウントを作成しておきましょう。

Wranglerをインストールする

Cloudflare Workersで関数のビルド・プレビュー・デプロイをするときにはWrangler(ラングラー)という公式の提供するCLIを使うことになります。ここではWranglerをグローバルインストールしておきます。

npmでインストールする場合

$ npm install -g @cloudflare/wrangler

:::message warning
2021/02/07時点ではM1 Mac + npmで@cloudflare/wranglerをインストールすることができない(#1685)ので、cargoを使います。
:::

cargoでインストールする場合

cargoはRustのパッケージマネージャーです。cargoが入っていない場合はcurl https://sh.rustup.rs -sSf | shでインストールします。

$ cargo install wrangler

これでインストール完了です。

wrangler configにアカウントを紐付ける

続いてCLIにアカウントを紐付けます。wrangler configを実行すると、API Tokenを聞かれるので、Cloudflareのダッシュボードで取得したものを入力します。

API Tokenの取得について

Cloudflareのダッシュボードの「Managing API Tokens and Keys」というようなメニューから発行できます。

今回はCloudflare Workersを動かすので[Edit Cloudflare Workers]のテンプレートを選んでTokenを生成しました。

スターターを使ってCloudflare Workersのプロジェクトを作成

Cloudflare Workersにはたくさんのスターター(テンプレート)が用意されています。

https://developers.cloudflare.com/workers/starters

今回はTypeScriptのスターターを使います。

$ wrangler generate my-app https://github.com/EverlastingBugstopper/worker-typescript-template
M1 Macでエラーが発生する場合

2021/02/06時点だとM1 Macでwrangler generateを実行すると以下のようなエラーが発生します。

Error: could not download `cargo-generate`
no prebuilt cargo-generate binaries are available for this platform

仕方がないのでスターターのGitHubリポジトリをcloneしてwrangler.tomlをプロジェクトのルートに配置します。その後、ドキュメントを参考にwrangler.tomlにCloudflareアカウントのaccount_idなどを指定しておきます。

これでwrangler generateを実行したときと同じ構成になると思われます。

生成されたプロジェクトのルートにwrangler.tomlというものがあると思います。こちらにCloudflareのアカウントIDやデプロイしたときのWorkerの名前(URLにも含まれる文字列)を指定します。

wrangler.tomlの書き方はこちら →

Examplesを参考にしてWorkerを作る

公式のExamplesが充実しているため、困ったらまず同じようなことをやっているサンプルがないか探してみると良いでしょう。

https://developers.cloudflare.com/workers/examples

今回とくに参考にしたのは以下のサンプルです。

基本的な書き方

Cloudflare WorkersではFetch Listener(addEventListener('fetch', (event) => {}))というものでHTTPリクエストを受けることになります。

addEventListener("fetch", (event) => {
  return event.respondWith(
    new Response("Hello world")
  )
})

引数のeventにはヘッダー情報やパラメーターが含まれているほか、レスポンスを生成する際にも使います。

雑にCORS設定

ブラウザからリクエストを受けるAPIで、CORS設定が必要になる場合はプリフライトリクエスト(OPTIONSリクエスト)に応じるようにします。今回は単純にマークダウンをHTMLに変換するだけのAPIなので、すべてのオリジンを許可してしまいます。

addEventListener('fetch', (event) => {
  const request = event.request // requestオブジェクトもeventに含まれている

  // リクエストメソッドがOPTIONSの場合はプリフライトリクエストとみなす
  if (request.method === 'OPTIONS') {
    return event.respondWith(
      new Response(null, {
        headers: {
	  'Access-Control-Allow-Origin': '*',
	  'Access-Control-Allow-Methods': 'POST,OPTIONS', // POSTリクエストを受け付ける
          'Access-Control-Max-Age': '86400',
          'Access-Control-Allow-Headers': 'Content-Type'
	}
      })
    )
  }

  // ...あとで...
})

JSONを返す

Cloudflare WorkersでレスポンスとしてJSONを返すには以下のような書き方をします(ほぼ公式のサンプルの通り)。

addEventListener("fetch", event => {
  const data = {
    message: "Hello!"
  }

  const json = JSON.stringify(data)

  return event.respondWith(
    new Response(json, {
      headers: {
        "content-type": "application/json;charset=UTF-8"
      }
    })
  )
})

POSTリクエストbodyを取得する

POSTリクエストのbodyはContent-Typeapplication/jsonの場合、request.json()で取得できます(参考にしたサンプル)。

addEventListener("fetch", (event) => {
  const request = event.request
  const { markdown } = await request.json()
}

最終的な実装例

以下の処理を行うAPIの実装サンプルを載せておきます。

  1. POSTリクエストでbodyのmarkdownを受け取る
  2. markedでマークダウンをHTMLへと変換
  3. jsonで{ html: "変換後のHTML" }を返す
index.ts
import marked from "marked"

function handleOptions(request: Request) {
  return new Response(null, {
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'POST,OPTIONS',
      'Access-Control-Max-Age': '86400',
      'Access-Control-Allow-Headers': 'Content-Type'
    }
  })
}

async function handlePost(request: Request): Promise<Response> {
  if (!request.headers.get('content-type')?.includes('application/json')) {
     return new Response('Invalid Content Type', {
       status: 415,
     })
  }

  const { markdown } = await request.json()
  const html = marked(markdown) // マークダウンをHTMLに変換
  const json = JSON.stringify({ html })

  return new Response(json, {
    headers: {
      'content-type': 'application/json;charset=UTF-8',
    },
  })
}


addEventListener('fetch', (event) => {
  const request = event.request

  // プリフライトリクエストに応じる
  if (request.method === 'OPTIONS') {
    return event.respondWith(handleOptions(request))
  }

  // POSTリクエスト以外はエラーに
  if (request.method !== 'POST') {
    return event.respondWith(
      new Response('Method Not Allowed', {
        status: 405,
      }),
    )
  }

  event.respondWith(handlePost(request))
})

これで実装自体は完了です。

localhostで動かしてみる

下記のコマンドを実行するとlocalhostでサーバーが立ち上がり関数を試すことができます。

$ wrangler dev
# 👂 Listening on http://127.0.0.1:8787

デプロイする

下記のコマンドを実行すると、Cloudflare Workersにデプロイされて外部からリクエストを受け付けられるようになります。

$ wrangler publish

Cloudflare Workersを試してみた感想

レスポンスが速く、コールドスタートによる遅延のようなものがない(ように感じられる?)のが嬉しいです。今後ガンガン使っていこうと思います。

Discussion

JJJJ

今はNode.js側でmarkedを使って実行してるので多分同じマークダウンの量でもクライアントでやった方がhttpのオーバーヘッド分を超えるパフォーマンスが出るのかなと思ったりしてますが、これをwasmに変えてみたり、別の言語で書いてみたりしてどのぐらいでEdgeでやった方が早いみたいな分岐点が来るのか気になりますね👀


追記です
ユースケースを想定できてなかったのですが例えばgithubにあるmdをhtmlに変換するなどzennにありそうなケースの場合は、確かにCloud Functionでやってるからそこを

コールドスタートの遅さを解消したい
Cloud Functionsよりパフォーマンスが良さそう
(おそらく)料金的にも安くなる

から解決したいみたいなモチベーションですね。

管理画面的なものとか、zenn.devでclient上でpreviewをしてる部分などの変換に使うのかと思ってコメントしました

catnosecatnose

違いないですね。そのあたりを今後いろいろと試していきたいです。

Node.jsやC/Kotolin/Dartなどの対応言語でしかできない処理を任せるのにはかなり良さそうです。これまでCloudFunctionsやAWS Lambdaに任せていたことを今後は積極的にCloudflare Workersでやっていきたい気持ちです。