moonbit-js-binding について
MoonBitのJSバックエンドを使ってJavaScript APIを接続する手順を示すスキルです。FFI宣言、型の対応付け、Promiseの非同期処理、パッケージ出力設定を扱います。
主な機能
- 01JavaScriptとMoonBitの型対応
- 02JS FFI関数の宣言
- 03名前付きモジュールのエクスポートをインポート
- 04Promiseと非同期呼び出しの橋渡し
- 05MoonBitのJSパッケージ出力設定
- 06nullとundefinedの扱い
活用シーン
- 01ブラウザーDOMやNode.js APIのバインド
- 02npmパッケージ関数のMoonBitラッパー作成
- 03ESM、CJS、IIFE形式の出力設定
- 04TypeScript宣言ファイルの生成
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ mizchi/ skills --skill moonbit-js-bindingインストール後、Claude Code で/moonbit-js-bindingを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
README.mdassets/js_binding_proj/moon.modassets/js_binding_proj/src/async_test.mbtasync.mbtcross_target_test.mbtcross_target.mbtffi.mbtlib_test.mbtlib.mbtmodules_test.mbtmodules.mbtmoon.pkgreferences/cfg-and-target-gating.mderror-handling.mdinterop-patterns.mdpromise-bridging.mdtypescript-integration.md詳細
MoonBit JS バインディングガイド
js バックエンドを通じて、JavaScript API(ブラウザー DOM、Node の組み込み機能、npm パッケージ)を MoonBit にバインドする手順です。
MoonBit v0.10.12 および
moonbitlang/async0.21.0 で動作確認済みです。
使用する場面
次の場合に、このスキルを使用してください。
- JS API の
extern "js" fn宣言を追加する場合 - インライン JS(
#| (x) => ...)を埋め込む場合、またはモジュール名と名前でインポートする場合(= "Math" "max") link.jsエクスポートおよびformat: esm/cjs/iifeを使ってmoon.pkgを設定する場合#external pub typeと%identityキャストで JS オブジェクトをラップする場合async fn+%async.suspend+%async.runで Promise を橋渡しする場合undefinedとnullを MoonBit のOptionに区別してマッピングする場合require(...)または ESM インポートで npm パッケージを利用する場合- TypeScript の利用者向けに
.d.tsを生成する場合
計画モードの指示
計画モードで moonbit-js-binding に関する計画を作成する場合:
- 最終的な計画ファイルの冒頭に、実装を始める前に moonbit-js-binding スキルを読むよう、実行エージェントに明示的に指示してください。
- 指示は次のように記述してください。
**Before starting implementation:** Use the Skill tool to load the moonbit-js-binding skill, which provides comprehensive guidance on `extern "js"` declarations, JS value wrapping, Promise bridging, and moon.pkg export configuration.
型のマッピング
宣言を書く前に、JS の型を MoonBit の型にマッピングしてください。
| JavaScript | MoonBit | 備考 |
|---|---|---|
boolean | Bool | 直接対応 |
number(整数) | Int、UInt | JS は IEEE-754 を使用します。大きな Int64 は精度が失われるため、代わりに BigInt を使用してください |
number(浮動小数点数) | Float、Double | 直接対応 |
bigint | BigInt | 直接対応 |
string | String | 直接対応(どちらも UTF-16) |
Uint8Array | Bytes、FixedArray[Byte] | 直接対応。コピーは不要です |
Array<T> | Array[T]、FixedArray[T] | 直接対応 |
Function | FuncRef[(...) -> R] またはクロージャ | 直接対応 |
any(不透明型) | #external pub type JsValue | 公称型でラップします |
T | undefined | is_undefined のチェックによる T? | Null/Undefined のセクションを参照してください |
T | null | Nullable[T] ラッパー | null は undefined ではありません |
Promise<T> | #external pub type Promise[T] | async fn/wait() で橋渡しします |
定数 enum | number | pub(all) enum E { A; B } |
警告 — Number の精度: JavaScript には整数型がありません。FFI 境界を越える値は IEEE-754 の倍精度浮動小数点数に丸められます。
\|x\| < 2^53であればIntで問題ありません。それを超える場合はBigIntを使用してください。UInt64/Int64は現在、JS のnumberを介してマッピングされるため、2^53を超えると下位ビットが失われます。
警告 — トレイトオブジェクトと MoonBit の列挙型:
Map[K, V]、Result[_]、Json、または#externalではないトレイトオブジェクトを FFI 経由で渡すと、MoonBit の内部ランタイム形状が露出します。先にプレーンな構造体、JsValue、またはプリミティブ型に変換してください。
ワークフロー
以下の 4 つのフェーズを順番に進めてください。
フェーズ 1:プロジェクトのセットアップ
モジュールとパッケージを JS 出力向けに設定します。
モジュール(moon.mod) — preferred_target を設定し、moon check、moon build、moon test のデフォルトターゲットを js にします。v0.10.9 以降、コンパイラー自身のデフォルトターゲットは wasm です。そのため、これを設定しないと、すべてのコマンドに --target js が必要になります。
name = "user/pkg"
version = "0.1.0"
source = "src"
preferred_target = "js"
import {
"moonbitlang/[email protected]",
}
JSON 形式の
moon.mod.json(ハイフン区切りのpreferred-target、source、depsキーを使用)は非推奨で、削除が予定されています。moon fmtは 既存のプロジェクトを DSL に移行します。
パッケージ(src/moon.pkg) — .mbt ファイルの対象を js バックエンドに限定し、リンク出力を設定します。
ファイル形式 —
moon.pkg(DSL)とmoon.pkg.json(JSON): MoonBit はどちらのファイル名も引き続き読み取りますが、JSON のサポートは非推奨で、削除が予定されています。このスキルでは全体を通して DSL 形式のmoon.pkg(拡張子なし)を使用します。moon.pkg.jsonは書かないでください。これは別の構文であり、純粋な JSON で、options(...)ラッパーも末尾のカンマも使いません。プロジェクトの途中で両者を混在させると、「Unable to readmoon.pkg」という解析エラーが発生します。.pkg.jsonを使う古いテンプレートをクローンした場合は、moon fmtを実行して変換してください。targets:— バックエンド固有のファイルにのみ必要です:targets:ブロックを使うと、個々の.mbtファイルを一部のバックエンドに限定できます。extern "js"、extern "c"、またはバックエンド固有の#cfgブロックを含むファイルは、必ず一覧に記載してください。純粋な MoonBit ファイル(FFI なし)にはtargets:エントリーは不要です。パッケージ内のすべてのファイルが純粋な MoonBit であれば、targets:ブロック自体を省略してください。上の例ではffi.mbtとasync.mbtにextern "js"が含まれているため一覧に記載しています。FFI のないシンプルなライブラリでは、link: { ... }だけが必要です。
import {
"moonbitlang/async",
} for "test"
options(
targets: {
"ffi.mbt": ["js"],
"async.mbt": ["js"],
"async_test.mbt": ["js"],
},
link: {
"js": {
"exports": ["add", "greet"],
"format": "esm",
},
},
)
主なフィールド:
| フィールド | 目的 |
|---|---|
targets | .mbt ファイルを ["js"] に限定します。JS 専用の FFI ファイルは、他のバックエンドで問題が起きないよう、必ず限定してください。 |
link.js.exports | 出力モジュールから再エクスポートする公開関数の一覧です。名前を変更するには "name:alias" を使用します。 |
link.js.format | "esm"(ES モジュール。最新のバンドラーでのデフォルト)、"cjs"(Node の require)、または "iife"(ブラウザーのスクリプトタグ)です。 |
import ... for "test" | moonbitlang/async はテスト(async test)でのみ必要です。本番ビルドには含めないでください。 |
警告 —
supported-targetsとtargetsの違い: パッケージレベルでsupported-targets: ["js"]を使用しないでください。下流の利用者に制限がかかります。代わりにtargetsで個々のファイルを限定してください。
警告 —
preferred_target = "js": これはデフォルトであり、固定ではありません。moon test --target wasm-gcは、対応するファイルで引き続き動作します。これはクロスバックエンドライブラリに便利です。
バックエンドごとにコードを限定する方法は 2 つあります — targets:(ファイルレベル)と #cfg()(宣言レベル):
| 方法 | 適用範囲 | 使用する場面 |
|---|---|---|
moon.pkg の targets: { "foo.mbt": ["js"] } | ファイル全体 | ファイル全体が JS 固有の場合(FFI 専用、バックエンド限定のロジック)。JS コードが他のバックエンドと状態を共有する必要がない場合に、最もすっきりした方法です。 |
宣言に付ける #cfg(target="js") | 単一の fn、const、またはブロック | 共有部分が大半のモジュールで、1 つのシンボルだけにバックエンド固有の実装が必要な場合。関連するコードを 1 つのファイルにまとめられます。 |
#cfg(...) の中で使われる条件は次のとおりです。
#cfg(target="js") // JS only
#cfg(target="native") // native only
#cfg(not(target="js")) // everything except JS
#cfg(any(target="wasm", target="wasm-gc")) // either wasm backend
#cfg(any(target="native", target="llvm")) // either non-GC backend
#cfg(false) // always exclude (quick disable)
moonbitlang/core では、このパターンが広く使われています。たとえば Int16::from_int64 は、ネイティブ/wasm では %i64_to_i16 組み込み関数を使いますが、64 ビット整数にネイティブ表現がない JS では代替実装を使います。
///|
#cfg(not(target="js"))
pub fn Int16::from_int64(self : Int64) -> Int16 = "%i64_to_i16"
///|
#cfg(target="js")
pub fn Int16::from_int64(self : Int64) -> Int16 {
Int16::from_int(self.to_int())
}
#cfg にも同じファイルレベルのルールが適用されます。関数を呼び出せるすべてのターゲットに、対応する定義がちょうど 1 つ存在しなければなりません。 #cfg ガードが重複するとコンパイルエラーになります。#cfg(target="js") と #cfg(not(target="js")) を組み合わせればすべてのターゲットをカバーできます。組み込み関数ではない宣言に #cfg(target="js") だけを指定すると、その関数は他のバックエンドには存在しなくなります。その関数を呼び出すコード自体も #cfg で限定するか、targets: で限定したファイルに置く必要があります。
どちらを選ぶか: ファイル全体が extern "js" ラッパーであれば、targets: でファイルを限定してください。よりシンプルで、moon check --target wasm-gc ではファイル全体がスキップされます。ロジックの 90% が共有され、一部の関数だけが異なるクロスバックエンドライブラリを作成する場合は、#cfg を使ってください。
フェーズ 2:FFI レイヤー
extern "js" 宣言を記述します。非公開にするか、ffi_ を接頭辞として付け、フェーズ 3 で安全なラッパーを公開してください。
インライン JS(#| リテラル):
///|
extern "js" fn ffi_console_log(msg : String) -> Unit =
#| (msg) => console.log(msg)
///|
extern "js" fn ffi_sqrt(x : Double) -> Double =
#| (x) => Math.sqrt(x)
#| リテラルは、生の JS ソースとしての関数本体です。MoonBit コンパイラーは呼び出し箇所にインライン展開するため、実行時にクロージャーは割り当てられません。
モジュールと名前の形式(既存の JS 関数を直接インポートします):
///|
extern "js" fn math_max(a : Double, b : Double) -> Double = "Math" "max"
クロージャーでラップせずに、グローバルにアクセス可能な関数を参照する場合に使用します。わずかに高速で、サイズも小さくなります。
#module("pkg") — コンパイル時の ESM/CJS インポート:
JS モジュール(Node の組み込みモジュール、npm パッケージ、ローカルの .js ファイル)から名前付きエクスポートを使う場合は、extern "js" に #module("module-id") を付けます。右辺はそのモジュール内のエクスポート名です(インライン JS 本体ではありません)。
///|
#module("node:path")
extern "js" fn path_basename(p : String) -> String = "basename"
///|
#module("node:path")
extern "js" fn path_extname(p : String) -> String = "extname"
コンパイラーが出力する内容:
link.js.format | 生成されるコード |
|---|---|
"esm" | import { basename as basename$7 } from "node:path"; |
"cjs" | const { basename: basename$1548 } = require("node:path"); |
"iife" | 同じ CJS 形式の分割代入(IIFE 内でラップ) |
手書きの require("...") ラッパーより #module() が優れている理由:
- 静的に解析できます。 Vite / esbuild / Rollup は依存関係を追跡し、ツリーシェイクを行い、パスを正しく解決できます。
- ソースがフォーマットに依存しません。 同じ
.mbtファイルが、requireが定義されていない ESM と CJS の両方で動作し、別のコードパスも不要です。 - 生成コードを小さくできます。 呼び出しごとのクロージャーは不要で、分割代入したローカル変数が再利用されます。
- バンドラーと互換性のあるパスに対応します。 npm パッケージ指定子(
"lodash")、Node 組み込みモジュール("node:path")、相対ファイル("./helpers.js")、およびランタイムがサポートする場合は URL 形式の指定子を使用できます。
制約:
- 名前付きエクスポートのみです。 RHS はモジュールからエクスポートされたキーでなければなりません。デフォルトエクスポート、またはトップレベルの値自体が関数であるモジュールの場合は、JS 側で再エクスポートするブリッジが必要です。または、FFI 本体内に
import/requireをインラインで記述してください。 - 宣言ごとにエクスポートは 1 つです。 複数の
#moduleFFI 間で分割代入を共有することはできません。各アノテーションは独自のインポート行を生成します。コンパイラーはモジュール指定子を重複排除するため、関数ごとではなく、一意のモジュールごとに 1 回のrequireで済みます。 - モジュール名のリテラルはコンパイル時定数でなければなりません。 動的なモジュールパス(
#module(some_variable))はサポートされていません。モジュールを動的に選択する場合は、インライン JS のrequire(name)を使用してください。 - インライン本体は指定できません。
#module("pkg")+extern "js" fn f() = "name"の形式のみ使用できます。#| (...) => ...と組み合わせるとコンパイルエラーになります。 - ブラウザーのグローバルには不要です。
window、document、fetch、Mathなどには、インライン JS またはモジュール+名前の形式(= "Math" "max")を使用してください。これらはインポートされるのではなく、アクセスされるだけです。 - 副作用のあるインポートには、引き続き副作用があります。 コンパイル済みモジュールの読み込み時に
import/requireが実行されます。pkgにトップレベルの副作用(CLI バナー、グローバルのパッチ適用)がある場合、FFI 関数を一度も呼び出さなくても、インポート時に実行されます。 - テストとリリースでフォーマットが異なります。
moon testはlink.js.formatの設定にかかわらず、CJS ハーネスを使用します。モジュールが ESM 専用(package.jsonのexportsに CJS エントリーポイントがない)の場合、moon build --releaseが動作してもテストが失敗する可能性があります。該当するテストを条件付きにするか、両方の形式に対応したパッケージを使用してください。
形式の使い分け:
| 形式 | 使用する場面 |
|---|---|
#|(x) => ... インライン | 小さな式、複雑なロジック、または複数の JS 呼び出しを 1 つのラッパー内で組み合わせる必要がある場合 |
= "Math" "max" モジュール+名前 | 定義済みのグローバル関数を呼び出す場合(モジュールのインポートは不要) |
#module("pkg") + = "name" | インポート可能なモジュール(Node 組み込みモジュール、npm パッケージ、ローカルファイル)の名前付きエクスポートを呼び出す場合 |
require("pkg").get("name") ヘルパー | モジュール名が動的な場合、または実行時にしかモジュール名がわからない場合 |
複数行のインライン JS:
///|
extern "js" fn ffi_do_work(x : Int) -> Int =
#| (x) => {
#| const y = x * 2;
#| return y + 1;
#| }
不透明な JS 値のパターン:
MoonBit に相当する型がない値(DOM ノード、npm オブジェクト、undefined、null)は、#external pub type でラップしてください。
///|
#external
pub type JsValue
///|
/// Zero-cost bitcast — compiles to nothing.
pub fn[A, B] identity(a : A) -> B = "%identity"
%identity はコンパイラー組み込み機能です。型チェッカーのレベルで値を別の型に移し、JS コードは出力しません。プリミティブ値を JsValue に変換し、また戻すために使用してください。実行時には検証されません。誤った使い方をすると、型エラーが発生しても気づけません。
JsValue に対する汎用 FFI 操作:
///|
pub extern "js" fn JsValue::get(self : JsValue, key : String) -> JsValue =
#| (obj, key) => obj[key]
///|
pub extern "js" fn JsValue::set(
self : JsValue,
key : String,
value : JsValue,
) -> Unit =
#| (obj, key, value) => { obj[key] = value }
///|
pub extern "js" fn JsValue::call_method(
self : JsValue,
name : String,
args : Array[JsValue],
) -> JsValue =
#| (obj, name, args) => obj[name](...args)
この 3 つのプリミティブ(get / set / call_method)を使うと、事実上あらゆる JS オブジェクトとのやり取りを表現できます。
null / undefined の区別:
MoonBit の T? では T | null | undefined を安全に表現できません。Some(null) になってしまいます。is_undefined / is_null を使って明示的に確認してください。
///|
pub extern "js" fn is_undefined(v : JsValue) -> Bool =
#| (v) => v === undefined
///|
pub fn[T] js_get_opt(obj : JsValue, key : String) -> T? {
let v = obj.get(key)
if is_undefined(v) {
None
} else {
Some(identity(v))
}
}
null と undefined を区別するライブラリ(例:FileReader.error)では、Nullable[T] と Nullish[T] のラッパー型を導入し、明示的なチェックで絞り込んでください。
フェーズ 3:MoonBit API
生の ffi_* externs をラップする、安全で型付きの pub ラッパーを構築します。
///|
pub fn console_log(msg : String) -> Unit {
ffi_console_log(msg)
}
///|
pub fn sqrt(x : Double) -> Double {
if x < 0 { panic() }
ffi_sqrt(x)
}
JS コンシューマー向けのエクスポート:
moon.pkg の link.js.exports に記載されているものはすべて pub である必要があります。コンパイラーは、それぞれに名前付き ESM エクスポート(または CJS プロパティ、IIFE グローバル)を生成します。公開シグネチャから .d.ts が自動生成されます。
#export_name(v0.10.4) は、単一のpub非ジェネリック関数に対して、生成されるシンボル名を固定します。新しいエクスポートでは、バックエンド固有のexportsリンク設定よりもこちらが推奨されます。ジェネリック関数や省略可能な引数を持つ関数では動作せず、名前はパッケージ内で一意な、有効な C 識別子である必要があります。#export_name("add_ints") pub fn add(a : Int, b : Int) -> Int { a + b }エクスポート設定は、アーティファクトを生成するパッケージに適用されます。依存関係内の属性によって、出力にシンボルが追加されることはありません。
フェーズ 4:Promise/非同期ブリッジ
MoonBit の非同期処理では、JS の Promise 内部処理に変換されるコンパイラー組み込み機能を 2 つ使用します。
| 組み込み機能 | 役割 |
|---|---|
%async.suspend | 再開/拒否コールバックのペアを await ポイントに変換します |
%async.run | 同期コンテキストから非同期計算を開始します |
標準的なセットアップ(詳細は references/promise-bridging.md を参照)。各項目は前の項目に依存するため、この順番で宣言します。
///|
#external
pub type Promise[T]
///|
/// Cast Promise[T] to JsValue so JsValue methods (get/call_method) apply.
/// Required by `Promise::wait` below.
pub fn[T] Promise::to_any(self : Promise[T]) -> JsValue = "%identity"
///|
pub async fn[T, E : Error] suspend(
f : ((T) -> Unit, (E) -> Unit) -> Unit,
) -> T raise E = "%async.suspend"
///|
pub fn run_async(f : async () -> Unit noraise) -> Unit = "%async.run"
///|
/// The bridge. `await promise` in JS = `promise.wait()` in MoonBit.
pub async fn[T] Promise::wait(self : Promise[T]) -> T {
suspend(fn(ok, err) {
self.to_any().call_method("then", [identity(fn(v : JsValue) { ok(identity(v)) })])
.call_method("catch", [identity(fn(e : JsValue) { err(identity(e)) })])
|> ignore
})
}
%async.run と %async.suspend のどちらが必要ですか?
| 状況 | run_async が必要ですか? | 理由 |
|---|---|---|
async test "..." { ... } ブロック | いいえ | テストハーネスがすでにブロックを非同期コンテキストで実行します |
.wait() を呼び出す pub async fn | いいえ | 呼び出し元はすでに非同期であり、await チェーンが自然に機能します |
| Promise を開始して JS に返す、エクスポート済みの MoonBit 同期関数 | はい | 同期から非同期へ切り替えるため、run_async が本体をスケジュールします |
async fn エグゼキューターから Promise[T] を構築する | はい | new Promise(...) は同期処理ですが、エグゼキューター本体を実行する必要があります |
要するに、async test を使う場合、run_async を記述する必要はありません。同期コンテキストから非同期計算を %identity で JS の Promise にキャストする場合にのみ必要です。
標準パターン — Promise を返す FFI + 非同期ラッパー:
///|
extern "js" fn ffi_fetch_text(url : String) -> Promise[String] =
#| (url) => fetch(url).then(r => r.text())
///|
pub async fn fetch_text(url : String) -> String {
ffi_fetch_text(url).wait()
}
呼び出し側は、async test または async fn 内で let body = fetch_text(url) と記述します。呼び出し箇所で .wait() を呼び出す必要はありません。テスト:
async test "fetch_text resolves" {
let body = fetch_text("https://example.com")
inspect(body.length() > 0, content="true")
}
注 —
moonbitlang/async:async testブロックを使用するには、moon.pkgにimport { "moonbitlang/async" } for "test"が必要です。この依存関係が必要なのはテスト時のみです。moon add moonbitlang/asyncで追加します。バージョン 0.17.0 以降が必要です(古い 0.1.x リリースは、現在の%async.suspendABI をサポートしていません)。0.21.0で動作確認済みです。0.20 より後にアップグレードする際は、次の破壊的変更に注意してください。
@http.HeadersはMap[String, String]ではなく、大文字と小文字を区別しないマップになりました。@fsからcreate/truncateパラメーターが削除され、@fs.write_fileのデフォルトはCreateOrTruncateになりました。@stdio.{Input,Output}::fdは値を返す代わりに例外を発生させます。@async.protect_from_cancelのresume_on_cancel=trueがデフォルトになりました。ランタイムは、デッドロックを検出すると処理を中断します(設定には@async.set_deadlock_handlerを使用してください)。
フェーズ 5:テスト
moon check # type-check, fast
moon test # run all tests (js is default)
moon test src --filter "greet" # filter by name
moon build # emit .js + .d.ts to _build/js/release/build/
ビルド出力パス — デバッグとリリース:
| コマンド | 出力ディレクトリ |
|---|---|
moon build(デフォルト) | _build/js/debug/build/<pkg>.js + .d.ts |
moon build --release | _build/js/release/build/<pkg>.js + .d.ts |
moon test(自動) | _build/js/debug/test/<pkg>.{internal,blackbox}_test.js |
.d.ts はどちらのモードでも生成されます。npm に公開する場合は、--release を使用してください。ローカルで試す場合は、デフォルトの debug 出力で十分であり、リンクもより高速です。
生成されたモジュールを Node から実行する:
# debug build
node --input-type=module -e 'import("./_build/js/debug/build/<pkg>.js").then(m => console.log(m.add(2,3)))'
# release build (after `moon build --release`)
node --input-type=module -e 'import("./_build/js/release/build/<pkg>.js").then(m => console.log(m.add(2,3)))'
実行可能なエンドツーエンドのサンプルは、このスキルの assets/js_binding_proj/ にあります。最小限のプロジェクトで同期・非同期のテストが 8 件成功する例です。ディレクトリ内の内容をコピーして調整し、moon test で実行してください。
判断表
| 状況 | パターン | 主な操作 |
|---|---|---|
| JS のグローバルを呼び出す(1 行で記述) | アロー関数を使ったインラインの #| | extern "js" fn ... = #| (x) => ... |
| 既存のグローバルを参照する(ラップなし) | モジュール+名前の形式 | = "Math" "max" |
| npm/Node 組み込みモジュールから名前付きエクスポートを参照する | #module("pkg") + = "exportName" | コンパイラーが import { exportName } または require を出力するため、バンドラーと相性がよい |
| 不透明な JS オブジェクト | #external pub type T | %identity でラップしてキャストする |
JS の any コンテナー | get/set/call_method を備えた単一の JsValue 型 | オブジェクトプロトコルのヘルパーを一度作成して再利用する |
T | undefined | is_undefined(v) で確認し、T? を返す | Some(null) に意味があると決して仮定しない |
T | null(undefined とは区別される) | Nullable[T] ラッパー型 | is_null で明示的に分岐する |
| JS Promise | #external type Promise[T] + wait() | %async.suspend でブリッジする |
| JS の利用側にエクスポートする | moon.pkg の link.js.exports | pub 関数を列挙する。.d.ts は自動生成される |
| ファイル全体を 1 つのバックエンドに限定する | moon.pkg の targets: { "f.mbt": ["js"] } | ファイル全体がバックエンド固有の場合に最もすっきりする |
| 単一の関数を 1 つのバックエンドに限定する | fn の上に #cfg(target="js") を記述する | ファイルの 90% が共有される場合に使う。ほかのバックエンドには #cfg(not(target="js")) を併用する |
大きな整数(|x| > 2^53) | Int64 ではなく BigInt | JS の number は 2^53 を超えるとビット精度が失われる |
| npm パッケージを呼び出す | require("pkg") をラップし、JsValue.get("fn") を使う | references/interop-patterns.md を参照 |
| JS 例外を捕捉する | JS 側で呼び出しを try { ... } catch { ... } でラップする | references/error-handling.md を参照 |
よくある落とし穴
-
ファイルを
targetsで限定し忘れる。extern "js"を含むファイルが["js"]に限定されていないと、moon check --target wasm-gcとnativeが失敗します。FFI ファイルは必ず限定してください。 -
T | null | undefinedの表現にT?を使う。 MoonBit のNoneは JS のnullではなく、Some(null)は意味のない値です。値を変換する前にis_undefinedとis_nullで分けてください。 -
FFI をまたいで
Int64に依存する。 JS のnumberは IEEE-754 倍精度浮動小数点数です。2^53 を超える 64 ビット整数は、警告なしに丸められます。BigIntを使うか、2 つのIntに分割してください。 -
Map/Result/トレイトオブジェクトをそのまま渡して MoonBit の内部表現を公開する。 これらは MoonBit 固有のランタイムレイアウトを持ちます。境界でプレーンな構造体、Array[(K, V)]、またはJsValueに変換してください。 -
async testにmoonbitlang/asyncが必要なことを忘れる。 テスト専用のインポートは忘れやすいものです。最初のasync testを書く前に、moon.pkgにimport { "moonbitlang/async" } for "test"を追加してください。 -
同期 JS から非同期関数を呼び出す際に
%async.runを忘れる。 エクスポートされた同期関数ではawaitを使えません。同期エクスポートから非同期処理を開始する必要がある場合は、run_async(async fn() noraise { ... })でラップし、Promiseを明示的に返してください。 -
asyncコンテキスト外で.wait()を呼び出す。.wait()はasyncです。コンパイルできるのはasync fnまたはasync testの内部だけです。非同期ではない呼び出し側は、Promise をそのまま受け取る必要があります。 -
実際に変換が必要な箇所で
%identityを使う。%identityは型チェッカーを回避するためのもので、ランタイムへの影響はありません。IntとStringのキャストに使うと、警告なしにランタイムエラーになります。JS のランタイム表現が実際に一致する場合にのみ使用してください。 -
link.js.exportsに含まれないpub関数をエクスポートする。ほかの MoonBit パッケージからは呼び出せますが、コンパイル済み JS モジュールからは再エクスポートされません。関数名をexportsに追加してください。
サンプルプロジェクト
上記のすべてのパターンを示す、最小限の実行可能なプロジェクトです。
assets/js_binding_proj/
├── moon.mod # preferred_target = "js", moonbitlang/async dep
└── src/
├── moon.pkg # targets + link.js.exports
├── ffi.mbt # extern "js" + JsValue + inline JS
├── lib.mbt # Safe public wrappers (add, greet, js_get_opt)
├── lib_test.mbt # Sync tests
├── async.mbt # Promise[T] + suspend + wait
├── async_test.mbt # async test blocks
├── modules.mbt # #module("node:path") ESM/CJS import demo
├── modules_test.mbt
├── cross_target.mbt # #cfg(target="...") per-declaration gating demo
└── cross_target_test.mbt
実行方法:
cd assets/js_binding_proj
moon test # 12 tests pass (sync + async + #cfg + #module)
moon build # emits ESM + .d.ts
node --input-type=module -e 'import("./_build/js/debug/build/js_binding_proj.js").then(m => console.log(m.add(2,3), m.greet("world"), m.basename("/a/b/c.html")))'
# → 5 Hello, world! c.html
参考資料
@references/promise-bridging.md @references/interop-patterns.md @references/error-handling.md @references/typescript-integration.md @references/cfg-and-target-gating.md