Svelte の self-contained apps で WebAssembly を埋め込む方法


# はじめに

2024年の暮れ、Svelte の [Advent of Svelte](https://svelte.dev/blog/advent-of-svelte)にて[Day 22: self-contained apps](https://svelte.dev/blog/advent-of-svelte#Day-22:-self-contained-apps)が公開されました。これは Svelte で実装したものを1つの `index.html` ファイルにまとめる機能です。

便利な点は、`index.html` ファイルを共有するだけでアプリを配布できることです。

例えば、 Web 上で提供されるサービスは、提供者の都合で見た目や動作が変わってしまうことがあります。しかし、`index.html` ファイルを共有する形式であれば、利用者の都合に合わせて一定のバージョンを保持できます。（こういった需要がどの程度あるかは未知数ですが...）

# 動機

self-contained apps ですが、WebAssembly には対応していません（ビルドしても `wasm` ファイルは独立したままです）。もし WebAssembly にも対応できれば、高度な機能を備えたアプリを `index.html` だけで共有できるのでは？という発想から検証してみました。

# この記事の前提知識

- WebAssembly の概要程度の知識
- Linux をかじった程度の知識
- [Svelte](https://svelte.dev/) の基礎知識

# 方針検討

JavaScript で wasm ファイルを読み込む際は [WebAssembly.instantiateStreaming()](https://developer.mozilla.org/ja/docs/WebAssembly/Reference/JavaScript_interface/instantiateStreaming_static) を使うのが一般的です。第一引数の `source` は [Response](https://developer.mozilla.org/ja/docs/Web/API/Response) 型であり、通常は[fetch()](https://developer.mozilla.org/ja/docs/Web/API/Window/fetch) を使って取得します。

`fetch()` の第一引数は URL なので [データURL](https://developer.mozilla.org/ja/docs/Web/URI/Schemes/data) 形式を使用できます。つまり、`wasm` ファイルを Base64 エンコードすれば `index.html` 内に埋め込むことが可能になります。

```js:例
const wasm = await WebAssembly.instantiateStreaming(
                 fetch(`data:application/wasm;base64,AGFzbQEAAAABkYCAgA...ICw==`)
             );
```

# 実践

以下の手順で検証します。
1. WebAssembly 環境の構築
2. 簡単な WebAssembly アプリを実装
3. 簡単な Svelte アプリを実装

## 1. WebAssembly 環境の構築

Emscripten をインストールします。（Ubuntu環境を前提）

```sh:ターミナル
$ sudo apt update
$ sudo apt upgrade
$ sudo apt install cmake
$ sudo apt install emscripten
$ emcc -v
emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 3.1.5 ()
Ubuntu clang version 13.0.1-2ubuntu2.2
Target: wasm32-unknown-emscripten
Thread model: posix
InstalledDir: /usr/bin
```

## 2. 簡単な WebAssembly アプリを実装

C言語のコードを作成します。

```c:sample.c
#include <stdio.h>

int add100(int num) {
  return num + 100;
}
```

wasmファイルを生成します。

```:ターミナル
$ emcc sample.c -o add100.mjs -s EXPORTED_RUNTIME_METHODS=cwrap -s EXPORTED_FUNCTIONS=['_add100'] -s ENVIRONMENT=web -s SINGLE_FILE --no-entry
```

ここで重要なのは `-s SINGLE_FILE` です。これにより `wasm` ファイルが Base64 文字列に変換されて `add100.mjs` に埋め込まれます。
参考：https://emscripten.org/docs/tools_reference/settings_reference.html#single-file

また、Svelte で import できるようにするため `add100.mjs` のように拡張子を `mjs` に設定しました。


## 3. 簡単な Svelte アプリを実装

あとはこれを Svelte アプリとして実装してビルドするだけです。

Svelte プロジェクトを作成します。

```:ターミナル
$ npx sv create myapp
Need to install the following packages:
sv@0.6.18
Ok to proceed? (y) y

┌  Welcome to the Svelte CLI! (v0.6.18)
│
◇  Which template would you like?
│  SvelteKit minimal
│
◇  Add type checking with Typescript?
│  Yes, using Typescript syntax
│
◆  Project created
│
◇  What would you like to add to your project? (use arrow keys / space bar)
│  none
│
◇  Which package manager do you want to install dependencies with?
│  npm
│
◆  Successfully installed dependencies
│
◇  Project next steps ─────────────────────────────────────────────────────╮
│                                                                          │
│  1: cd myapp                                                             │
│  2: git init && git add -A && git commit -m "Initial commit" (optional)  │
│  3: npm run dev -- --open                                                │
│                                                                          │
│  To close the dev server, hit Ctrl-C                                     │
│                                                                          │
│  Stuck? Visit us at https://svelte.dev/chat                              │
│                                                                          │
├──────────────────────────────────────────────────────────────────────────╯
│
└  You're all set!
$ cd myapp
$ npm i -D @sveltejs/adapter-static
$ npm install
```

self-contained apps を有効化します。

```js:svelte.config.js
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
const config = {
        // Consult https://svelte.dev/docs/kit/integrations
        // for more information about preprocessors
        preprocess: vitePreprocess(),

        kit: {
                // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
                // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
                // See https://svelte.dev/docs/kit/adapters for more information about adapters.
                adapter: adapter(),
                output: {
                        bundleStrategy: 'inline'
                },
                router: {
                        type: 'hash'
                }
        }
};

export default config;
```

`add100.mjs` をコピーします。

```:ターミナル
$ cp [WebAssembly実装ディレクトリ]/add100.mjs src/routes/ 
```

アプリの HTML を `src/routes/+page.svelte` に実装します。

```svelte:src/routes/+page.svelte
<script>
        import { onMount } from 'svelte';
        import initWasm from './add100.mjs';
        // 初期化関数
        async function init() {
                const module = await initWasm();
                // モジュール読み込みが完了するまで待つ
                await new Promise(resolve => {
                        if (module.onRuntimeInitialized) {
                                module.onRuntimeInitialized = resolve;
                        } else {
                                resolve();
                        }
                });
                // add100 関数を読み込む
                const add100 = module.cwrap(
                                        "add100",
                                        "number",
                                        ["number"]
                                );

                // ボタンをクリックされたら add100 を実行するように設定
                const buttonElm = document.querySelector("#button");
                buttonElm.addEventListener("click", () => {
                        const inputElm = document.querySelector("#input");
                        const result = add100(parseInt(inputElm.value));
                        inputElm.value = result;
                });
        }

        onMount(init);
</script>

<input id="input" type="number">
<button id="button">add 100</button>
```

ビルドします。

```:ターミナル
$ npm run build
```

index.html 単体で機能することを確認するため、あえてコピーします。

```:ターミナル
$ cp build/index.html [コピー先]/index.html
```

# 結果確認

`index.html` ファイルをダブルリックしてブラウザを起動して動作確認してみます。
![スクリーンショット 2025-02-03 142737.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2773745/4ab1722d-4335-f077-8df8-8d5f1bc20c0e.png)
「add 100」ボタンをクリックしてみます。
![スクリーンショット 2025-02-03 142748.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2773745/de57f50a-e5bf-4228-e206-edd26f45f998.png)
もう一回クリックしてみます。
![スクリーンショット 2025-02-03 142753.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2773745/753cc88e-958c-cdd7-43ae-98a8ee5261c6.png)
ちゃんと WebAssembly が機能してますね！

# まとめ

Svelte の self-contained apps で WebAssembly を実現する手順を検討しました。
結果、`emcc` コマンドのオプションを工夫することで実現可能であることがわかりました。

ローカル専用で WebAssembly を使ったアプリを手軽に作って共有できそうですね！:grinning: