虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

【WebAssembly連載第四回】WebAssemblyでフロントエンドでもサーバーサイドでも動くマークダウンライブラリを作る

本記事は「WebAssembly連載」の第三回目の記事です。

皆さんこんにちは。虎の穴ラボのY.Fです。

前回、前々回記事では、web-sysやjs-sysを使ってWebAssemblyでWeb APIを使う方法を紹介しました。

toranoana-lab.hatenablog.com

今回は、より具体的にWebAssemblyを利用してアプリケーションを作ってみたいと思います。

WebAssemblyの特徴である、ポータビリティと既存資産の活用で、フロントエンド、サーバーサイド共通ロジックで動作するマークダウンライブラリを作ってみたいと思います。

利用技術

サーバーサイドでは手軽にWebAssemblyを利用できるDenoを使います。 フロント側はいつもどおりRustでWebAssemblyを作ります

  • Deno: 1.29.4
  • fresh: 1.1.2
  • Rust: 1.66.1
  • wasmbuild: 0.10.3
    • Denoとwasmの連携用ツールです。以下サンプルを見ていただくと分かる通り、実態はwasm-bindgenになっています。

deno.com

準備

上記のwasmbuildの記事と、freshの初期化手順でプロジェクトを作成します。プロジェクト名はmarkdown-wasmとしておきます。

deno run -A -r https://fresh.deno.dev markdown-wasm

出来上がったプロジェクトの、deno.jsonに以下を追記します。

{
  "tasks": {
    "start": "deno run -A --watch=static/,routes/ dev.ts",
+   "wasmbuild": "deno run -A https://deno.land/x/wasmbuild@0.8.5/main.ts"
  },
  "importMap": "./import_map.json",
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "preact"
  }
}

以下コマンドでwasmbuildの準備を行います。

deno task wasmbuild new

これで準備完了です。

サーバーサイド、フロントエンド同一のロジックをWebAssemblyで作成する

試しに、最初から用意されている足し算関数をサーバーサイド、フロントエンド共通ロジックにしてみます。

初期化した状態では、rs_lib 配下のRust関数は以下のように定義されているかと思います。

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
  return a + b;
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn it_works() {
    let result = add(1, 2);
    assert_eq!(result, 3);
  }
}

このadd関数を利用します。

まずは、WebAssemblyをビルドします。

deno task wasmbuild

これで、 lib/rs_lib.generated.js ができるので、これをエントリポイントとして利用します。

main.tsでサーバー起動時にwasm初期化してみます。

  /// <reference no-default-lib="true" />
  /// <reference lib="dom" />
  /// <reference lib="dom.iterable" />
  /// <reference lib="dom.asynciterable" />
  /// <reference lib="deno.ns" />

  import {start} from '$fresh/server.ts';
  import manifest from './fresh.gen.ts';
+ import {instantiate} from './lib/rs_lib.generated.js';

+ await instantiate();
  await start(manifest);

サーバー側の処理を作成するにはroutes/index.tsxでaddを呼び出します。

import { Head } from "$fresh/runtime.ts";
import Counter from "../islands/Counter.tsx";
import { add } from '../lib/rs_lib.generated.js';

export default function Home() {
+ const addResult = add(100, 1);
  return (
    <>
      <Head>
        <title>Fresh App</title>
      </Head>
      <div>
        <span>wasm add result: {addResult}</span>
        <img
          src="/logo.svg"
          width="128"
          height="128"
          alt="the fresh logo: a sliced lemon dripping with juice"
        />
        <p>
          Welcome to `fresh`. Try updating this message in the ./routes/index.tsx
          file, and refresh.
        </p>
        <Counter start={3} />
      </div>
    </>
  );
}

次に、フロント側の処理を作成するにはislandフォルダ以下にtsxファイルを作成します。

今回はAdd.tsxというファイル名で以下の内容を記述します。

import { useEffect, useState } from "preact/hooks";
import { JSX } from "preact/jsx-runtime";
import { Button } from "../components/Button.tsx";
import { add, instantiate } from "../lib/rs_lib.generated.js";

interface State {
  count: number;
  addLeft: number;
  addRight: number;
  isWasmReady: boolean;
}

export default function Add() {
  const [state, setState] = useState<State>({
    count: 0,
    addLeft: 0,
    addRight: 0,
    isWasmReady: false,
  });

  useEffect(() => {
    instantiate({ url: new URL("/rs_lib_bg.wasm", location.origin) }).then(
      () => {
        setState({ ...state, isWasmReady: true });
      },
    );
  }, []);

  const onClick = () => {
    if (!state.isWasmReady) return;

    const count = add(state.addLeft, state.addRight);
    setState({
      ...state,
      count: count,
    });
  };

  const onLeftChange = (e: JSX.TargetedEvent<HTMLInputElement>) => {
    setState({ ...state, addLeft: Number(e.currentTarget.value) || 0 });
  };

  const onRightChange = (e: JSX.TargetedEvent<HTMLInputElement>) => {
    setState({ ...state, addRight: Number(e.currentTarget.value) || 0 });
  };

  return (
    <div>
      <div>
        <Button disabled={!state.isWasmReady} onClick={onClick}>
          足し算を実行する
        </Button>
      </div>
      <input type="number" value={state.addLeft} onInput={onLeftChange} />
      +
      <input type="number" value={state.addRight} onInput={onRightChange} />
      =
      <span>{state.count}</span>
    </div>
  );
}

フロント側では、*.wasmファイルは instantiate 時にdynamic importして読み込まれます。単に instantiate してしまうと、相対パスで読み込まれるため、パスが _frsh/js/hogehoge.wasm などになってしまい、正常に読み込めません。

そこで、 instantiate 関数の引数にオプションとしてwasmのURLを記述します。また、freshで静的ファイルを配布するには、staticフォルダ配下にwasmファイルを何らかの形で配布しておく必要があります。今回は lib 配下に生成されるwasmへのハードリンクを作成することにしました。

他にも、wasmbuildの設定で出力先を lib 以外に変えることもできるので、 static 配下にする方法も取れるかと思います。

WebAssemblyでマークダウンを扱う

次に、Rustで作っているWebAssemblyでマークダウンを扱えるようにしてみます。

既存の資産を利用したいため、今回は以下のライブラリを利用します。

https://crates.io/crates/pulldown-cmark

まずは、rs_lib/Cargo.tomlに以下追記します。

  [package]
  name = "rs_lib"
  version = "0.0.0"
  edition = "2021"

  [lib]
  crate_type = ["cdylib"]

  [profile.release]
  codegen-units = 1
  incremental = true
  lto = true 
  opt-level = "z"

  [dependencies]
+ pulldown-cmark = "0.9.2"
  wasm-bindgen = "=0.2.83"

rs_lib/src/lib.rsで上記ライブラリを利用します。

use pulldown_cmark::{html, Options, Parser};
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
  return a + b;
}

#[wasm_bindgen]
pub fn parse_markdown(markdown_input: &str) -> String {
  let mut options = Options::empty();
  options.insert(Options::ENABLE_STRIKETHROUGH);
  let parser = Parser::new_ext(markdown_input, options);

  let mut html_output = String::new();
  html::push_html(&mut html_output, parser);
  html_output
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn it_works() {
    let result = add(1, 2);
    assert_eq!(result, 3);
  }

  #[test]
  fn parse_test() {
    let html_output = parse_markdown(
      "Hello world, this is a ~~complicated~~ *very simple* example.",
    );

    let expected_html = "<p>Hello world, this is a <del>complicated</del> <em>very simple</em> example.</p>\n";
    assert_eq!(expected_html, &html_output);
  }
}

add関数に加えて、parse_markdown及びそれに対するテストを追加しました。テストの実行はrs_libフォルダで cargo test とすることで実行できます。

これで、deno task wasmbuild ができればWebAssemblyの生成は無事完了です。

WebAssemblyで作ったマークダウンライブラリをサーバーサイドでもフロントエンドでも扱う

では、上記作ったマークダウンライブラリを組み込んでみます。

routes/md.tsxを作ります。

import { Head } from "$fresh/runtime.ts";
import Markdown from "../islands/Markdown.tsx";
import { parse_markdown } from "../lib/rs_lib.generated.js";

const README = `
# WebAssemblyでマークダウンライブラリを作る

この文章はサーバーサイドでWebAssemblyを利用して、マークダウンから作られたものです。

利用技術は以下になります。

- Rust
- [pulldown_cmark](https://crates.io/crates/pulldown-cmark)
- Deno
  - fresh
`;

export default function Md() {
  const mdHTML = parse_markdown(README);
  return (
    <>
      <Head>
        <title>Fresh App</title>
      </Head>
      <div dangerouslySetInnerHTML={{__html: mdHTML}}>
      </div>
      <Markdown />
    </>
  );
}

ここでは、サーバー側の処理として、説明文をマークダウンで記述し、HTMLを生成しています。

次に、上記で利用しているislands/Markdown.tsxを作成します。

import { useEffect, useState } from "preact/hooks";
import { JSX } from "preact/jsx-runtime";
import { instantiate, parse_markdown } from "../lib/rs_lib.generated.js";

interface State {
  md: string;
  html: string;
  isWasmReady: boolean;
}

const TEXTAREA_STYLE = {
    width: 500,
    height: 800
};

export default function Add() {
  const [state, setState] = useState<State>({
    md: "",
    html: "",
    isWasmReady: false,
  });

  useEffect(() => {
    instantiate({ url: new URL("/rs_lib_bg.wasm", location.origin) }).then(
      () => {
        setState({ ...state, isWasmReady: true });
      },
    );
  }, []);

  const onInput = (e: JSX.TargetedEvent<HTMLTextAreaElement>) => {
    if (!state.isWasmReady) return;

    const md = e.currentTarget.value;
    const html = parse_markdown(md);
    setState({ ...state, html, md });
  };

  return (
    <div style={{display: "flex"}}>
      <textarea style={TEXTAREA_STYLE} disabled={!state.isWasmReady} value={state.md} onInput={onInput} />
      <div dangerouslySetInnerHTML={{ __html: state.html }}></div>
    </div>
  );
}

フロントエンドの処理として、textareaに入力されたマークダウンがリアルタイムで右側に表示されるようにしてみました。

実際に出来上がったものは以下になります。

注意点

dangerouslySetInnerHTMLを使っているのを見ていただくと分かる通り、出来上がったHTMLに安全な保証が無い限りは、xssなど発生する危険性があります。

HTMLのサニタイズ処理もWebAssembly側に任せることで、こちらもフロント、サーバーで処理を共有できます。

ちょうど pulldown_cmark と合わせた利用方法が紹介されていたので、今回は以下を利用します。

https://crates.io/crates/ammonia

では組み込んでみます。

use ammonia::clean;
use pulldown_cmark::{html, Options, Parser};
use wasm_bindgen::prelude::*;

// ...

#[wasm_bindgen]
pub fn parse_markdown(markdown_input: &str) -> String {
  let mut options = Options::empty();
  options.insert(Options::ENABLE_STRIKETHROUGH);
  let parser = Parser::new_ext(markdown_input, options);

  let mut html_output = String::new();
  html::push_html(&mut html_output, parser);
  clean(&*html_output)
}

#[cfg(test)]
mod tests {
  // ...

  #[test]
  fn parse_xss_test() {
    let html_output = parse_markdown("XSS<script>alert(1);</script>");

    let expected_html = "<p>XSS</p>\n";
    assert_eq!(expected_html, &html_output);
  }
}

基本的にはammoniaのサンプル通りです。

テストケースにあるように、危険な部分は消される形になります。

実際に、危険なタグ要素を入れてみても以下のように消されます。

まとめ

今回の記事では、WebAssemblyのポータビリティと、既存資産の流用を活かしてアプリを作ってみました。

今回はマークダウンでしたが、ものによってはWysiwygエディタをサーバーサイドレンダリングしたい時なども利用できるかと思います。

WebAssemblyは高速化の面が強調されがちですが、ポータビリティ的な面でも注目できる点があると思います。

P.S.

採用

虎の穴では一緒に働く仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧下さい。
yumenosora.co.jp