Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Cloudflare WorkersにScala.jsをデプロイした

意外とやったらできたので記事にします。

実際に動いている様子はこちら。

cloudflare-worker-scala-exercise.pages.dev

ソースコードはこちらです。

github.com

感想

先に感想を書くと……

  • 案外サクっと作れた
  • 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とかでやるとかっこいいんだろうな

やること

と、いっても以下の記事のマネをしただけです。

blog.indoorvivants.com

とはいえそれだけだと味気ないので、ざっくり説明します。

Cloudflare Workersってなによ

そもそもCloudflare Workers(以下、CF Worker)って何よ、と思われるかもしれませんが、これはCloudflareのエッジでなんらかのNodeのコードが動くよ、という便利メカニズムです。僕はOGイメージの生成とかに使っています。こうするとクライアントに一番近い位置のエッジで画像が生成されてお得というわけです。

CF Workersにはいくつかの関連プロダクトがあるので、いったんこれも紹介しましょう。

まず、Workersです。これは元々はService Workersを動作させてCDNの補完的役割を担うプロダクトでした。同じようなプロダクトとしてホスティング環境であるPagesというのがあったのですが、これらは統合されて単にPagesとかWorkerとか呼べるようになりました。

www.publickey1.jp

なんかちょっと厄介ですが、両者が合体することにより、「エッジにコードがデプロイされてなんか返せる」というフルスタックなプロダクトになった、というわけです。

さて、そんなPagesですが、そのサブプロダクトとしてFunctionsというのがあります。要するに、GoogleにおけるCloud Functionsみたいなやつで、AWSにおけるLambda、と言ってしまえばそれだけなのですが、これを利用することで、ハンドラーだけを備えた素朴なコードをそのままCloudflareのエッジで動作させることができるようになります。今回はこれを使ってScala.jsを動かします。

www.serversus.work

このへん結構難しいですね。

目指す構造

CF Functionsでは、最終的に以下のようなファイル構造を用意すれば自動的にルーティングしてくれます。

functions
├── index.js // ほげほげ.pages.dev/ に相当
├── request_headers.js // ほげほげ.pages.dev/request_headers に相当
...

そして、各ファイルは以下のような構造になっている必要があります。

export function onRequest(context) {
  ...
  return new Response(...);
}

GETリクエストのみ処理したい場合はonRequestのかわりにonRequestGetなどのように書きます。

developers.cloudflare.com

あとは、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がビルドを実行し、コードがデプロイされます。

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?