💨

ブラウザからもNode.jsからも import できるWebAssemblyライブラリを作る

2022/09/11に公開約3,000字1件のコメント

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.frommain.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

https://github.com/sosukesuzuki/js-yamlfmt/tree/main/packages/wasm

実際に Node.js から実行できる CLI ツールも、ブラウザから実行できる Playground アプリケーションも元気に動いている。

Discussion

はじめまして。この辺りの差異は自分も気になっていました。

勘違いのような気がして正直自信はないのですが、この目的であれば、WebAssembly.instantiateに渡す引数で処理を切り替えれば済むような気がしました。

async function getBuf(f) {
    if (isNode) {
        const fs = await import("node:fs/promises");
        return await fs.readFile(new URL(f, import.meta.url));
    } else {
        const response = await fetch(f);
        return await response.arrayBuffer();
    }
}

const { instance } = await WebAssembly.instantiate(await getBuf('main.wasm'));

つまり1ファイルに埋め込むことは必須ではないと考えたのですが、どうでしょうか。

もちろん埋め込むことによる利点もあると思いますが、Base64化でファイルサイズが33%大きくなることとは引き換えになりますね。

ログインするとコメントできます