意外とやったらできたので記事にします。
実際に動いている様子はこちら。
cloudflare-worker-scala-exercise.pages.dev
ソースコードはこちらです。
感想
先に感想を書くと……
- 案外サクっと作れた
- CloudFlare FunctionsのAPIとしてTSで用意されている型定義がScalaに変換されるときにたまに潰れてることがある
- TSだと普通に呼べるけどScalaに変換するとなんか呼べなくなってる、みたいなことがあるという意味
- 別にScalablyTyped使わずに手でJSいじっても動くので脱出ハッチはある
- 入出力さえなんとかすれば後は全部Scalaなので困ることはない
- 実行速度は問題なし(Nodeで動くので)
- ちょっとしたScalaのコードをデプロイしたいときは普通に便利そう
- Pure Scalaでよい(もしくはScala.jsに対応したライブラリを使う)とき
- 外部に素朴なAPIを公開したいとき
- CFが提供しているD2とかKVを動かしたいときは便利だと思う
- あるドメインの下にいくつかのAPIやエンドポイントをぶら下げたい、みたいな時はGoogleのCloud Functionsより便利だと思う
- Pagesのビルドツールはv1だとJavaに対応してるんだけど、v2では対応してないのでv2でやりたければ手元でScala.jsでビルドすることになりそう
- 今のところv1でなければsbtを走らせられない
- WASMも動くらしいので将来的にはWASMとかでやるとかっこいいんだろうな
やること
と、いっても以下の記事のマネをしただけです。
とはいえそれだけだと味気ないので、ざっくり説明します。
Cloudflare Workersってなによ
そもそもCloudflare Workers(以下、CF Worker)って何よ、と思われるかもしれませんが、これはCloudflareのエッジでなんらかのNodeのコードが動くよ、という便利メカニズムです。僕はOGイメージの生成とかに使っています。こうするとクライアントに一番近い位置のエッジで画像が生成されてお得というわけです。
CF Workersにはいくつかの関連プロダクトがあるので、いったんこれも紹介しましょう。
まず、Workersです。これは元々はService Workersを動作させてCDNの補完的役割を担うプロダクトでした。同じようなプロダクトとしてホスティング環境であるPagesというのがあったのですが、これらは統合されて単にPagesとかWorkerとか呼べるようになりました。
なんかちょっと厄介ですが、両者が合体することにより、「エッジにコードがデプロイされてなんか返せる」というフルスタックなプロダクトになった、というわけです。
さて、そんなPagesですが、そのサブプロダクトとしてFunctionsというのがあります。要するに、GoogleにおけるCloud Functionsみたいなやつで、AWSにおけるLambda、と言ってしまえばそれだけなのですが、これを利用することで、ハンドラーだけを備えた素朴なコードをそのままCloudflareのエッジで動作させることができるようになります。今回はこれを使ってScala.jsを動かします。
このへん結構難しいですね。
目指す構造
CF Functionsでは、最終的に以下のようなファイル構造を用意すれば自動的にルーティングしてくれます。
functions ├── index.js // ほげほげ.pages.dev/ に相当 ├── request_headers.js // ほげほげ.pages.dev/request_headers に相当 ...
そして、各ファイルは以下のような構造になっている必要があります。
export function onRequest(context) { ... return new Response(...); }
GETリクエストのみ処理したい場合はonRequest
のかわりにonRequestGet
などのように書きます。
あとは、Scala.jsが最終的にこの形式でビルドするようにすればよいわけです。
Scala.jsを書く
簡単です。
project/plugins.sbt
Scala.jsを利用するいつもの定義です。今回はビルド情報をページに表示したいのでsbt-buildinfo
を入れていますが、Scala.jsをCF Functionsで走らせたいだけなら要らないです。
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.0") addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0")
build.sbt
build.sbt
にはScala.js用の設定と、cloudflare用の型定義に依存する箇所と、デプロイ用のディレクトリを生成するタスクが定義されています。
val scala3Version = "3.3.1" lazy val root = project .in(file(".")) .enablePlugins(ScalaJSPlugin, BuildInfoPlugin) .settings( name := "cloudflare-worker-scala-exercise", version := "0.1.0-SNAPSHOT", scalaVersion := scala3Version, scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.ESModule) }, libraryDependencies += "org.scalameta" %% "munit" % "0.7.29" % Test, libraryDependencies += "com.indoorvivants.cloudflare" %%% "worker-types" % "3.3.0", buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion), buildInfoPackage := "buildinfo" ) lazy val buildWorkers = taskKey[Unit]("Copy Scala.js output to the ./function folder") buildWorkers := { // where Scala.js puts the generated .js files val output = (Compile / fastLinkJS / scalaJSLinkerOutputDirectory).value // trigger (if necessary) JS compilation val _ = (Compile / fastLinkJS).value // where (relative to root of our build) we want to copy them val destination = (ThisBuild / baseDirectory).value / "functions" // access SBT's logger, for ease of debugging val log = streams.value.log // .js files produced by Scala.js val filesToCopy = IO.listFiles(output).filter(_.ext == "js") if(destination.exists()) { // .js files already at the destination val filesToDelete = IO.listFiles(destination).filter(_.ext == "js") // delete stale .js files filesToDelete.foreach {f => log.debug(s"Deleting $f") } } // copy new .js files to destination filesToCopy.foreach {from => val to = destination / from.name log.debug(s"Copying $from to $to") IO.copyFile(from, to) } }
出力形式はESModuleで問題ないようでした。
src/main/scala/app/Main.scala
あとはScalaのコードを書くだけです。
package app import scala.scalajs.js import scala.scalajs.js.annotation.* import com.indoorvivants.cloudflare.cloudflareWorkersTypes.{global, *} import com.indoorvivants.cloudflare.std import scala.scalajs.js.annotation.JSExportTopLevel type Params = std.Record[String, scala.Any] @JSExportTopLevel(name = "onRequestGet", moduleID = "index") def index(context: EventContext[Any, String, Params]) = val r = global.Response(s"""<!DOCTYPE html> |<html> | (略) |</html>""".stripMargin) r.headers.set("content-type", "text/html") r // (略)
@JSExportTopLevel(name = "onRequestGet", moduleID = "index")
している箇所が一番重要です。これを定義することで、Scala.jsはindex
というモジュールにこの関数を出力すればよいのだな、と判断してくれます。そしてexportするときの名前はonRequestGet
なのだな、と理解してくれます。デフォルトではScala.jsは適当にエクスポート名を決めてくれる(Scala同士で呼び出す限りはそれで良い)のですが、@JSExportTopLevel
で意図的に明示することでScala.jsはこの設定をリスペクトするようになります。
ビルドする
コードをビルドするにはsbt buildWorkers
することで、functions
ディレクトリが生成されます。がこれは手でやる必要はなく、Functionsの設定でやれば勝手にやってくれます。
CloudFlare側で自動化できるように、以下のようなスクリプトをdeploy.sh
として用意しておきます。
#!/bin/sh curl -Lo sbt https://raw.githubusercontent.com/sbt/sbt/v1.9.8/sbt chmod +x ./sbt ./sbt buildWorkers
次にCloudFlare側にアプリケーションを作成します。
次にPagesを作成します。このとき、GitHubと連携させることで勝手にビルドできるようにします。
するとGitHubのリポジトリを選べるようになるので、コードが入ったリポジトリを選択します。
この後ビルド設定を聞かれるのですが、FrameworkはNone
を選択します。そしてBuild commandとして./deploy.sh
を設定し、Build output directoryとしてfunctions
を指定すればOKです。
ここまで設定しきると自動的にCloudFlareがビルドを実行し、コードがデプロイされます。