ブラウザからもNode.jsからも import できるWebAssemblyライブラリを作る
WebAssembly というやつは便利で一度コンパイルしてしまえばブラウザでも Node.js でも実行できる。
でも .wasm
のファイルをどうやって読み込むのか、というのがブラウザと Node.js では違う。
色々やり方はあると思うけど、一番素直なやり方を考えてみる。
まずブラウザの場合は main.wasm
みたいなファイルを適当な場所に配置しておいて fetch
で内容を取得して WebAssembly.instantiate
に食わせるとかになると思う。
const response = await fetch("main.wasm");
const buf = await response.arrayBuffer();
const { instance } = await WebAssembly.instantiate(buf);
こんな感じ。
で、Node.jsの場合は main.wasm
ファイルを fs.readFile
とかで読み込んで WebAssembly.instantiate
に食わせるとかになると思う。
import fs from "node:fs/promises";
const buf = await fs.readFile(new URL("./main.wasm", import.meta.url));
const { instance } = await WebAssembly.instantiate(buf);
こんな感じ。
WebAssembly にコンパイルした何かを main.wasm
として配置し、取得し、インスタンス化する、という流れは両方同じ。
で、微妙に読み込み方が違うわけだけど、せっかくどこでも使える WebAssembly なのに透過的に使えないのはなんとなく惜しい感じがする。ライブラリとして提供できるのは main.wasm
のファイルだけってことになってしまう。
ということで、ブラウザでも Node.js でも気にせずに import したらいい感じに使えるようになっているとうれしいね。
ライブラリのユーザーは何も気にせずこういうコードを書けるとうれしい。
import { doSomething } from "@sosukesuzuki/something";
await doSomething();
方針としてはこう。
- ライブラリのビルド時に
main.wasm
を Base64 にエンコードする - それをあらかじめライブラリコードの中に埋め込む
- ライブラリが呼び出されている環境が Node.js なのかブラウザなのかを動的に判断する
- 判断した結果に応じて適切な方法で Base64 から復元して
WebAssembly.instantiate
に食わせる
で、結論として次のスクリプトをライブラリのビルド時に実行することで実現できる。
import fs from "node:fs/promises";
const mainWasm = await fs.readFile(new URL("../main.wasm", import.meta.url)); // ①
const mainWasmBase64 = Buffer.from(mainWasm, "binary").toString("base64"); // ②
const module = `
const source = '${mainWasmBase64}'; // ③
const isNode = // ④
typeof process !== 'undefined' &&
process.versions != null &&
process.versions.node != null;
export function getBuf(): any {
if (isNode) {
return Buffer.from(source, "base64"); // ⑤
} else {
// ⑥
const raw = globalThis.atob(source);
const rawLength = raw.length;
const buf = new Uint8Array(new ArrayBuffer(rawLength))
for(var i = 0; i < rawLength; i++) {
buf[i] = raw.charCodeAt(i)
}
return buf;
}
}
`;
await fs.writeFile(new URL("../src/wasm.ts", import.meta.url), module, "utf-8"); // ⑦
- ①:
main.wasm
ファイルをfs.readFile
で読み込む - ②:
Buffer.from
でmain.wasm
を Base64 にした Buffer を作る - ③: ② で作った Buffer を文字列化したものをコードに埋め込む
- ④: グローバル変数
process
の中身を見ることでランタイムが Node.js かどうか判定する - ⑤: ④ の結果ランタイムが Node.js だと判断した場合、
Buffer.from
で Base64 化したmain.wasm
をデコードして返す - ⑥: ④ の結果ランタイムがブラウザだと判断した場合、
atob
を使ってmain.wasm
をデコードし、Uint8Array
に結果を詰めて返す - ⑦: ここで作ったコードをライブラリのソースコードが配置されてる
src
ディレクトリにwasm.ts
として書き込む
こうすると、main.wasm
の内容が Base64 として書き込まれた Node.js / ブラウザ両対応のモジュールが src/main.ts
に生成されることになる。
main.wasm
の中身を WebAssembly.instantiate
に渡せる形で読み込むところまでが違うだけなので、このモジュールを作ったあとは適当に WebAssembly.instantiate
するだけで良い。
この方法は @rollup/plugin-wasm
の手法を参考にしてスクリプトにしたもの。責務としてはたしかにモジュールバンドラーに近い。
そしてこの方法を使って作ったのが @js-yamlfmt/wasm
。
実際に Node.js から実行できる CLI ツールも、ブラウザから実行できる Playground アプリケーションも元気に動いている。
Discussion
はじめまして。この辺りの差異は自分も気になっていました。
勘違いのような気がして正直自信はないのですが、この目的であれば、
WebAssembly.instantiate
に渡す引数で処理を切り替えれば済むような気がしました。つまり1ファイルに埋め込むことは必須ではないと考えたのですが、どうでしょうか。
もちろん埋め込むことによる利点もあると思いますが、Base64化でファイルサイズが33%大きくなることとは引き換えになりますね。
Node.js 18 で fetch が使えるようになったので. このData URL を使う形で ブラウザでも Node.js でも実行できるようになりましたね