레지스트리를 암호화해 저장하는 프로그램인 Zandam을 개발하면서, ‘Self-extractor를 Wasm과 HTML로 작성하면 크로스플랫폼 지원이 매우 간단하지 않을까’ 라는 생각을 해 보았다. 그런데 사용자 입장에서 여러 개의 파일(JS module, Wasm module, HTML 등)을 들고 다니면 매우 번거롭기 때문에, 이들을 하나의 HTML 파일로 우겨넣는 방법을 찾아야 했다.

wasm-bindgen: Rust ❤️ WebAssembly

Rust는 웹어셈블리 지원을 주된 세일즈 포인트로 잡기 때문에, 관련 라이브러리도 잘 발달되어 있다. wasm-bindgen은 Rust 코드를 몇 개의 attribute(파이썬, 자바의 decorator와 유사하다고 생각하면 편하다)만 추가해 WebAssembly 모듈로 바꿀 수 있게 해 준다. 간략하게 살펴보자.

// lib.rs

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn add_two(a: u8, b: u8) -> u8 {
    a + b
}
# Cargo.toml

[package]
name = "..." # 생략

[dependencies]
wasm-bindgen = "^0.2.58" # wasm-bindgen crate 추가

[lib]
crate-type = ["cdylib"] # wasm-pack 과정에 필요, Wasm 외에 Rust 라이브러리로도 사용해야 한다면 "rlib"도 추가해야 한다.

이후 wasm-pack 명령어를 사용해 해당 모듈을 빌드할 수 있다.

$ wasm-pack build

[INFO]: Checking for the Wasm target...
[INFO]: Compiling to Wasm...
   Compiling mylib v0.1.0 (/home/test/rust/mylib)
    Finished release [optimized] target(s) in 1.69s
:-) [WARN]: origin crate has no README
[INFO]: Installing wasm-bindgen...
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended
[INFO]: :-) Done in 4.23s

그러면 해당 crate의 pkg 폴더 안에 Wasm 모듈이 생성된다. (mylib.js, mylib_bg.wasm)

자바스크립트에서 해당 모듈을 사용하려면 다음과 같이 임포트 후 사용한다.

  • 참고: 사실 node.js에서 Webpack을 사용해 해당 모듈을 번들링해야 하지만, 나도 잘 모르는 부분이고 결론적으로 이 방법을 쓰는 게 아니기 때문에 생략한다. 자세한 것은 이 부분을 참고하자.
const rust = import('./pkg');

rust
  .then(m => console.log(m.add_two(2, 5)))
  .catch(console.error);

더 자세한 설명은 wasm-bindgen 가이드와 wasm-pack 문서를 참고하면 좋다.

의존성 줄이기: Webpack과 같은 번들러를 쓰지 않는 법

공식 문서에서도 Webpack 없이 직접 Wasm 모듈을 로드하는 법을 설명하고 있다. wasm-pack으로 빌드할 때 --target web 옵션을 주면 사전 번들링 작업 없이 브라우저에서 바로 코드를 실행할 수 있다. 이 때 .js 파일은 ES6 모듈로서 작동한다고 한다. (아직 브라우저에서 직접 Wasm 모듈을 불러올 수 없기 때문에 Javascript로 우회하여야 한다고 한다.)

<html>
  <head>
    <meta content="text/html;charset=utf-8" http-equiv="Content-Type"/>
  </head>
  <body>
    <script type="module">
      import init, { add_two } from './pkg/mylib.js';

      async function run() {
        await init();

        const result = add_two(1, 2);
        console.log(`1 + 2 = ${result}`);
        if (result !== 3)
          throw new Error("wasm addition doesn't work!");
      }

      run();
    </script>
  </body>
</html>
  • Tip: Chrome, Firefox와 같은 최신 브라우저에서 이 HTML 파일을 로컬로 저장한 다음 열면 동작하지 않을 것이다. 이는 CORS(Cross-Origin Resource Sharing) 정책에 의해 임의의 로컬 파일을 읽을 수 없도록 막아놓았기 때문이다. 파이어폭스의 경우에는 about:config에서 privacy.file_unique_origin 설정을 false로 바꾸면 동작하지만 이는 사실상 컴퓨터 내의 모든 파일을 읽게 허용하는 보안 위협이므로 잠깐만 활성화한 후 원래대로 돌려놓는 것이 좋다.

Output Image

이제 우리는 Wasm 모듈을 하나 작성했다! 다음 문제는 이 .js 파일과 .wasm 파일을 HTML 문서 하나로 합치는 것이다.

Inlining Wasm modules

먼저 간단한 편인 Wasm 모듈을 HTML 안으로 내장하자. .wasm 파일의 내용을 base64로 인코딩한 뒤, 자바스크립트 안에 하드코딩했다.

<html>
<!-- 중요하지 않은 정보들은 모두 생략했다. -->
<head>
</head>
<body>
    <script type="module">

        // base64 인코딩된 .wasm 파일
        let wasm_base64 = "AGFzbQEAAAABBwFgAn9/AX8DAgEABQMBABEHFAIGbWVtb3J5AgAHYWRkX3R3bwAACg0BCwAgACABakH/AXELAHsJcHJvZHVjZXJzAghsYW5ndWFnZQEEUnVzdAAMcHJvY2Vzc2VkLWJ5AwVydXN0Yx0xLjQxLjAgKDVlMWE3OTk4NCAyMDIwLTAxLTI3KQZ3YWxydXMGMC4xNC4wDHdhc20tYmluZGdlbhIwLjIuNTggKDI5MDJjZWIyNik=";
        let wasm_binary = Uint8Array.from(atob(wasm_base64), c => c.charCodeAt(0)).buffer;

    </script>
</body>
</html>

이렇게 .wasm 파일을 브라우저 메모리 상에 로드하였다. 다음은 Wasm 모듈의 코드를 자바스크립트 세계와 연결할 자바스크립트 모듈을 로드할 차례이다.

Inlining ES6 modules

해당 질문의 답변을 보면 ES6 모듈을 Blob으로 만들어 동적으로 <script> 태그를 생성하는 방법을 취하고 있다. 답변의 코드는 어느 정도 minify(사실은 난독화도 겸하는 것 같지만…)되어 있어, 방법론만 참고하여 읽기 좋게 다시 구현해 보았다.

<html>

<head>
    <meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
</head>

<body>
    <!-- mylib.js의 내용. type이 inline-module인 것에 유의 -->
    <script type="inline-module" id="wasm-bridge">
        let wasm;

        /**
        * @param {number} a
        * @param {number} b
        * @returns {number}
        */
        export function add_two(a, b) {
            var ret = wasm.add_two(a, b);
            return ret;
        }

        function init(module) {
            if (typeof module === 'undefined') {
                module = import.meta.url.replace(/\.js$/, '_bg.wasm');
            }
            let result;
            const imports = {};

            if ((typeof URL === 'function' && module instanceof URL) || typeof module === 'string' || (typeof Request === 'function' && module instanceof Request)) {

                const response = fetch(module);
                if (typeof WebAssembly.instantiateStreaming === 'function') {
                    result = WebAssembly.instantiateStreaming(response, imports)
                        .catch(e => {
                            return response
                                .then(r => {
                                    if (r.headers.get('Content-Type') != 'application/wasm') {
                                        console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
                                        return r.arrayBuffer();
                                    } else {
                                        throw e;
                                    }
                                })
                                .then(bytes => WebAssembly.instantiate(bytes, imports));
                        });
                } else {
                    result = response
                        .then(r => r.arrayBuffer())
                        .then(bytes => WebAssembly.instantiate(bytes, imports));
                }
            } else {

                result = WebAssembly.instantiate(module, imports)
                    .then(result => {
                        if (result instanceof WebAssembly.Instance) {
                            return { instance: result, module };
                        } else {
                            return result;
                        }
                    });
            }
            return result.then(({ instance, module }) => {
                wasm = instance.exports;
                init.__wbindgen_wasm_module = module;

                return wasm;
            });
        }

        export default init;
    </script>

    <!-- 기존 코드. type이 inline-module인 것에 유의 -->
    <script type="inline-module" id="main">
        let wasm_base64 = "AGFzbQEAAAABBwFgAn9/AX8DAgEABQMBABEHFAIGbWVtb3J5AgAHYWRkX3R3bwAACg0BCwAgACABakH/AXELAHsJcHJvZHVjZXJzAghsYW5ndWFnZQEEUnVzdAAMcHJvY2Vzc2VkLWJ5AwVydXN0Yx0xLjQxLjAgKDVlMWE3OTk4NCAyMDIwLTAxLTI3KQZ3YWxydXMGMC4xNC4wDHdhc20tYmluZGdlbhIwLjIuNTggKDI5MDJjZWIyNik=";
        let wasm_binary = Uint8Array.from(atob(wasm_base64), c => c.charCodeAt(0)).buffer;

        async function run() {
            await init(wasm_binary);

            const result = add_two(1, 2);
            console.log(`1 + 2 = ${result}`);
            if (result !== 3)
                throw new Error("wasm addition doesn't work!");
        }

        run();
    </script>

    <!-- ES6 Module Blob 생성 후 해당 URL을 사용하여 임포트하도록, 스크립트 내용을 바꿔치기한다 -->
    <script>
        let wasm_inline = document.querySelector("script#wasm-bridge[type=inline-module]")
        let wasm_elem = document.createElement("script");
        wasm_elem.setAttribute("type", "module");
        wasm_elem.setAttribute("generated", true);
        wasm_elem.textContent = wasm_inline.textContent;
        wasm_elem.src = URL.createObjectURL(new Blob([wasm_inline.textContent], { type: "application/javascript" }));
        wasm_inline.replaceWith(wasm_elem);

        let main = document.querySelector("script#main[type=inline-module]");
        let main_replace = document.createElement("script");
        main_replace.setAttribute("type", "module");
        main_replace.setAttribute("generated", true);
        if (main.id) main_replace.id = main.id;
        main_replace.textContent = `/* generated */ import init, { add_two } from "${wasm_elem.src}";\n\n` + main.textContent;

        main.replaceWith(main_replace)
    </script>
</body>

</html>

여기서 눈여겨볼 점은 main 스크립트의 init() 부분이다. init() 함수는 Uint8Array와 같은 Wasm 바이너리 배열이 주어지면 거기서부터 직접 로드하기 때문에, 이와 같은 작업이 가능하다.

Conclusion

지금까지 HTML 파일 안에 WebAssembly 모듈과 이를 로드하는 ES6 자바스크립트 모듈을 내장하는 방법을 살펴보았다. 언젠가 <script type="module"> 등에서 직접 Wasm 코드를 임포트하도록 통합되면 이 과정은 더 간단해질 것으로 보인다.