BiwaSchemeでWebアプリを書きたい
BiwaSchemeでyuniのライブラリシステムを導入する目処が立ったので、BiwaSchemeを使ってWebアプリのロジック部分を書けないか検討することにした。
BiwaSchemeには組込みのjqueryサポートが存在するが、今回は(個人的によく使っているので)mithril.jsベースのSPAを前提として考えることにする。
組込み
今回はビルドシステムとしてParcelを使うことにした。設定ファイルの類はほぼ不要(yarnで適当に依存を足すだけ)、エントリポイントのHTMLは至極シンプルで、
<!doctype html> <head lang="en"> <link rel="stylesheet" href="index.scss"> </head> <body> <script src="index.js"></script> </body>
だけになる。index.jsがJavaScript側のエントリポイントになり、ここでBiwaScheme等をロードしている。
var m = require('./node_modules/mithril'); var biwas = require('./node_modules/biwascheme'); var bfs = require('./node_modules/browserfs'); var root = document.body; m.render(root, "Hello."); // debug
Parcelのようなビルドシステムはコード内のrequire記述をパースし、依存関係を自動的に抽出する。
load相当処理の実装
Parcelで普通にビルドするとNode.js用のloadやdisplay実装が使われてしまうため、mithril.jsのXHR wrapperを使って適当にload相当の処理を実装する。
var errhook = function(e) { console.error(e); } var biwa = new biwas.Interpreter(errhook); m.request({ method: "GET", url: "/check.scm", deserialize: function(v){return v;}, }).then(function(str){ biwa.evaluate(str, function(res){m.render(root, res);}); });
ここでは、Schemeプログラム check.scm を / から読んでいる。Parcelのデフォルトでは dist ディレクトリがルートになるので、dist/check.scmにプログラムを置く必要がある。
js-invoke/async
BiwaSchemeは組込みでNode.jsのファイルシステムやjqueryのバインディングが付属してくるが、今回は他のアプリに合わせてBrowserfsやMithril.jsを採用するためそれらのバインディングを用意する必要がある。
基本的にはBiwaSchemeのドキュメントにあるようにJavaScript側でバインディングを書くことを想定しているように見えるが、コードの取り回しを考えるとJS側のコードを減らしScheme側の比重を高めたい。というわけで、コールバックを取る非同期APIのためのプリミティブとして js-invoke/async を用意してみた。
js-invoke/asyncは コールバックは引数の最後 を想定していて、非同期APIの呼び出し中はSchemeプログラムの実行をPauseし、コールバックが呼ばれるとSchemeコードの実行を再開する。js-invoke/asyncの返値はコールバックの引数そのものとなる。
// (js-invoke/async js-obj "method" args ...) biwas.define_libfunc("js-invoke/async", 2, null, function(ar){ var js_obj = ar.shift(); var func_name = ar.shift(); // FIXME: Require underscorejs for isString?? return new biwas.Pause(function(pause){ var cb = function(){return pause.resume(arguments);}; ar.push(cb); js_obj[func_name].apply(js_obj, ar); }); });
js-invoke/asyncを使ったSchemeコードは:
(define (object->string obj) (let ((p (open-output-string))) (write obj p) (get-output-string p))) (define the-result 0) (define x (js-closure (lambda (arg cb) (cb (+ arg 1))))) ;; myfuncの実体、引数1つとコールバックを受けとり1加算してcbに渡す (define theWindow (js-eval "window")) (js-set! theWindow "myfunc" x) ;; 適当にwindow.myfunc() を登録 (set! the-result (js-invoke/async theWindow "myfunc" 2)) ;; myfuncの呼び出し (myfunc 2 cb)、cbはScheme側を実行再開 (set! the-result (object->string (car (js-array->list the-result)))) the-result ;; 先のload相当処理で、返値がWebブラウザ上に表示される
このコードをdist/check.scmに配置すると、結果として"#(3)"が表示される(3 = 2 + 1)。(ベクタになっているのは、JavaScript側のコールバックが多値を返すケースを想定しているため。コールバックの引数が2つ以上であれば、その分長いベクタが返ることになる。)
... これで任意のJavaScript非同期APIを同期手続きのように呼び出せるが、そもそもJavaScript(〜ES5)には末尾再帰最適化が無いので、どこかで継続を打ち切るように書かないとスタックが伸びつづけてヤバい気はする。これはBiwaScheme側の実装をチェックしてみる必要が有りそう。
未解決問題
BiwaSchemeはNodeとブラウザの両方で load のようなAPIをサポートしているが、yarn経由で適当にビルドした場合BiwaScheme側のpackage.jsonに従ってNodeの方が使われているようだ。今回はmithril.jsを組込む都合、jqueryバインディングは不要だし何らかの形でコンフィギュレーションを切れると良い気がするが。。たぶんコレに関してはnpm的に直接的な解法はなくて、biwascheme-coreパッケージと同-node、-browserパッケージを用意して分割するしか無いと思う。
js-invoke/asyncのインターフェースは悩みどころで、もうちょっと抽象化したSchemeフレンドリなAPIの方が好ましいかもしれない。
まだyuniのビルドシステムとParcelを繋ぐ良い方法を思いついていない。たぶんParcelのプラグインとして書くのが良いと思うが、ParcelのCLIから使うためにはnpmパッケージとしてpublishしないとダメなんではなかろうか。。