Lambdaカクテル

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

Invite link for Scalaわいわいランド

esbuildでScala.jsをビルドして呼べるようにするプラグインを作った話

作った。

www.npmjs.com

github.com

esbuildとはJSのバンドラであり、めちゃくちゃ高速に動作するのがウリである。

esbuild.github.io

そして今回自分が作製したこのプラグインを使うと、esbuildがScalaのコードのimportを見付けると勝手にビルド・バンドルするので、TSやJSのコードを書いているときに突然Scalaのコードを呼べる:

// src/main/scala/Main.scala

package scalamain

import scala.scalajs.js
import scala.scalajs.js.annotation._

object Main {
  // JS側から見えるようにする処理
  @JSExportTopLevel("fib", moduleID = "scalamain")
  def fib(n: Int): Int = {
    if (n <= 1) n
    else fib(n - 1) + fib(n - 2)
  }
}
// src/main/js/main.js
import { fib } from 'scala:scalamain';

const main = async () => {
  console.log(fib(10));
}

main();
% node dist.cjs
55

この記事ははてなエンジニア Advent Calendar 2024の10日目の記事です。昨日はid:gurriumの『Maestroよかった』でした。世の中いろんなツールが発明されていて面白いですよね。

giarrium.hatenablog.com

しかも、Scala Advent Calendar 2024の記事でもあるのです。こんなことが許されるのか!? こちらの前回はid:xuweiの『CheerpJを使ってScalaをWebブラウザで動かす』でした。ブラウザでJVMをエミュレートするという謎技術があることにまず驚きました。WASMでJVMが動いたりしたら面白いかもしれませんね(放言)。

xuwei-k.hatenablog.com

設定

このプラグインを使うには、まずnpm iして

% npm i -D esbuild-scalajs

モジュール発見に必要な情報をオブジェクトとして用意し

const opts = {
  "scalaVersion": "3.5.2",
  "scalaProjectName": "esbuild-exercise",
  "scalaTargetFileExtension": "js",
}

プラグインとしてesbuildに渡すだけ。

import * as esbuild from 'esbuild'
import { scalaJsPlugin } from 'esbuild-scalajs'

const opts = {
  "scalaVersion": "3.5.2",
  "scalaProjectName": "esbuild-exercise",
  "scalaTargetFileExtension": "js",
}

await esbuild.build({
  entryPoints: ['main.js'],
  bundle: true,
  platform: 'node',
  outfile: 'dist.cjs',
  minify: true,
  plugins: [scalaJsPlugin(opts)],
})

あとはesbuildを起動するだけでいい:

// package.json
{
    "type": "module",
    "scripts": {
        "build": "node esbuild.mjs"
    },
    "devDependencies": {
        "esbuild": "0.24.0",
        "esbuild-scalajs": "^0.0.4"
    }
}
% npm run build

> @ build /home/windymelt/src/github.com/windymelt/esbuild-exercise
> node esbuild.mjs

2024.12.10 23:52:35:104 main INFO dev.capslock.esbuild.ScalaJsPlugin.scalaJsPlugin:23
    scalaJsPlugin 0.0.4 (Scala 3.6.2) is starting up...
2024.12.10 23:52:35:118 main INFO dev.capslock.esbuild.ScalaJsPlugin.setup:49
    running sbtn
[info] entering *experimental* thin client - BEEP WHIRR
[info] terminate the server with `shutdown`
> fastLinkJS
[success] Total time: 0 s, completed Dec 10, 2024, 11:52:35 PM
2024.12.10 23:52:35:207 main INFO dev.capslock.esbuild.NodeAPI.runCommand:15
    process exited
2024.12.10 23:52:35:208 main INFO dev.capslock.esbuild.ScalaJsPlugin.setup:49
    running sbtn
[info] entering *experimental* thin client - BEEP WHIRR
[info] terminate the server with `shutdown`
> fastLinkJS
[success] Total time: 0 s, completed Dec 10, 2024, 11:52:35 PM
2024.12.10 23:52:35:290 main INFO dev.capslock.esbuild.NodeAPI.runCommand:15
    process exited

面白いのは、ディレクトリ中にjsとscalaのソースが混在してるけど普通に動いていること。

仕組み

esbuildには、特定のファイルやimportに特化したプラグインを書く仕組みが備わっており、今回はそれを利用した。

esbuild.github.io

www.kabuku.co.jp

ざっくり言うと、name というstringのフィールドと、setupというビルド情報を受け取って各種フックを設定するためのメソッドとの2つを持つオブジェクトであれば、なんでもプラグインとして利用できる。実際にファイルの解決を行う処理はsetupで受け取るビルドオブジェクトに対してコールバックを設定していくという形になる:

// 抜粋
def setup(build: Build): Unit = {
    val isProd                           = process.env.NODE_ENV.map(_ == "production").getOrElse(false)
    val scalaProjectName                 = opts.scalaProjectName
    val scalaTargetDirSuffix             = if isProd then "-opt" else "-fastopt"
    var scalaTargetFileExtension: String = opts.scalaTargetFileExtension
    // if and only if cache miss is detected first, run sbtn
    build.onResolve(
      new OnResolveProps {
        val filter = js.RegExp("""^scala:.+""")
      },
      (args) => {
        // 別処理に切り出している
        onResolve(args, isProd, scalaProjectName, scalaTargetDirSuffix, scalaTargetFileExtension).toJSPromise
      },
    )
  }

そういえば書き忘れていたが、このプラグイン自体もScala.jsで書かれている。ロガーなどを入れたせいでバンドルサイズがデカくなってしまったが・・・

ともあれ、build.onResolveに注目してほしい。ビルドオブジェクトにこのハンドラを設定しておくと、esbuildが特定の正規表現にマッチするようなimport文に遭遇したときにコールバックされるようになる。プラグイン側ではこの情報を読み取り、目的のjsファイルへの絶対パスを返せば良い。あとはesbuildが勝手にそのファイルを読みに行ってバンドルしてくれる。我々がやるべきことは、適切なjsファイルを教えることだけ。

ちなみにjsファイルを教えるだけでなく、jsファイルを生成するところまでこのプラグインがやる。sbt(Scalaのビルドツール)の軽量なクライアントであるsbtnのプロセスを起動することで、非常に高速にビルドを捌けるようにした。sbt単体を起動すると数秒かかるが、sbtnは裏でビルドサーバにつなぎに行く上、ネイティブバイナリとして提供されているので爆速で起動してくれるのだ。

tototoshi.hatenablog.com

これにより、sbtnがESModuleまたはCommonJSを吐き出してくれるので、その生成先パスを予測してesbuildに渡すことでシームレスなビルドが成立するのだ。

苦労したところ

sbtの動作は決定的だし、Scala.js自体で困ることもあまりなかった(意外とexportまわりをキチンとハンドリングする手段があり、jsから呼ぶのには困らない)のだが、esbuildはGoで書かれていて、そのデータの受け渡しの過程でオブジェクトのプライベートなフィールド(具体的には、Symbolで定義されたフィールドなど)?あたりが壊れてしまって正常にコールバックが動作しないというトラブルを踏んだ。このへんは当該事象を踏まないような方法を発明(クラスをインスタンス化するのではなく、大域変数にパラメータを逃がすなど)してまともに動かせるようになった。

次に、npmに今回人生初めてpublishしたのだが、アクセストークンでpublishする方法がまったく上手くいかなかった。結局手でnpm publishを叩いてブラウザを開いてリリース、というやりかたになってしまった。

あまつさえ、当初はesbuild-plugin-scalajsという名前でpublishするつもりだったのが、publish失敗したタイミングで名前空間だけロックされ、なおかつwindymeltの所有ではない、という変な状態になってしまったらしく、全然この名前空間が使えなくなってしまった。しょうがないからesbuild-scalajsという名前でpublishしなければならなかった。そして、scalajs-esbuildという全く別のパッケージがあってまぎらわしい!(こちらはesbuildのプラグインではなく、sbtのプラグインになっているっぽい)

どこの文化でもpublishまわりは鬼門ということだろうか。

おわりに

楽しかった!Scala.jsで実用的なツールを作り、これをnpmにpublishし、実際に使えるようになった。もっと複雑で計算リソースが必要な処理だったら、 最近Scala.jsで使えるようになったWASMにビルドしても良かったのだが、まぁ今回は文字列をコネコネするくらいで良かったので普通にESModuleとしてビルドした。 今度はScala Nativeでなんか作りたいですね。

はてなエンジニア Advent Calendar 2024の明日の担当は id:tomato3713 です。 そしてScala Advent Calendar 2024の明日の担当は id:tanishiking24 です。

あ、週末にScalaわいわい勉強会あるんで来てください。

【オフライン】Scalaわいわい勉強会 #4【東京】 - connpass

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