作った。
esbuildとはJSのバンドラであり、めちゃくちゃ高速に動作するのがウリである。
そして今回自分が作製したこのプラグインを使うと、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よかった』でした。世の中いろんなツールが発明されていて面白いですよね。
しかも、Scala Advent Calendar 2024の記事でもあるのです。こんなことが許されるのか!? こちらの前回はid:xuweiの『CheerpJを使ってScalaをWebブラウザで動かす』でした。ブラウザでJVMをエミュレートするという謎技術があることにまず驚きました。WASMでJVMが動いたりしたら面白いかもしれませんね(放言)。
設定
このプラグインを使うには、まず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に特化したプラグインを書く仕組みが備わっており、今回はそれを利用した。
ざっくり言うと、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は裏でビルドサーバにつなぎに行く上、ネイティブバイナリとして提供されているので爆速で起動してくれるのだ。
これにより、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わいわい勉強会あるんで来てください。