uttk's site/articles/Quick.js と SWC を使って TypeScript のサンドボックス環境を Web Worker で実装する

Quick.js と SWC を使って TypeScript のサンドボックス環境を Web Worker で実装する

この記事について

フロントエンドでサンドボックス環境というと <iframe sandbox /> が有名ですが、それを使わずともサンドボックスを構築することは可能です。

しかし、<iframe sandbox /> 以外の方法は様々な理由からあまり使われておらず、参考となる情報も <iframe sandbox /> に比べると少ないため実装するのが難しい傾向にあります。

そのため今回は、サンドボックス環境を実装したい人の参考となれるように、そのサンドボックス環境を実装する時に一番選択肢から外されてしまう方法をあえて紹介していこうと思います。あえてね。

あと、普通に JavaScript のサンドボックス環境を実装するだけだと楽しさ半減してしまうので、TypeScript も実行できるサンドボックス環境を実装して行こうかなと思います。あえてね。

それではいってみよー💪

Vite + React で環境構築

まずは Vite + React の環境を整えます。create-vite を使ってひな型を作成しましょう 👇

$ pnpm create vite
.../1957e42d429-4028 |   +1 +
.../1957e42d429-4028 | Progress: resolved 1, reused 0, downloaded 1, added 1, done

  Project name:
  quickjs-and-swc-sandbox

  Select a framework:
  React

  Select a variant:
  TypeScript + SWC

  Scaffolding project in /quickjs-and-swc-sandbox...

  Done. Now run:

  cd quickjs-and-swc-sandbox
  pnpm install
  pnpm run dev

ひな型が作成出来たら、次に ./src/App.tsx を以下のように実装します 👇

./src/App.tsx
import * as Comlink from "comlink";
import { useState } from "react";
import MyWorker from "./workers/mod.ts?worker";

interface WorkerApi {
  eval(code: string): Promise<string>;
}

const myWorker = new MyWorker();
const workerApis = Comlink.wrap<WorkerApi>(myWorker);

function App() {
  const [tsCode, setTsCode] = useState("");

  const onEvalCode = async () => {
    console.log(`実行するコード:\n${tsCode}`);

    await workerApis
      .eval(tsCode)
      .then((result) => {
        alert(`実行結果: ${result}`);
      })
      .catch((error) => {
        alert(error.message);
      });
  };

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        gap: "8px",
        maxWidth: 600,
      }}
    >
      <textarea
        placeholder="実行したいソースコードを入力"
        value={tsCode}
        onInput={(e) => setTsCode(e.currentTarget.value)}
        style={{ minWidth: 450, minHeight: 300 }}
      />
      <button
        type="button"
        onClick={onEvalCode}
      >
        実行する
      </button>
    </div>
  )
}

export default App

実装内容は単純で <textarea /> に入力された TypeScript のコードを Web Worker に実装したサンドボックス環境で実行して結果を文字列として返します。

実装に Comlink というライブラリを使用していますが、これは Web Worker を簡単に扱えるようにしてくれるライブラリで、postMessage() などの面倒な通信処理を普通の非同期関数として実装できるようになります。( comlink は pnpm add comlink でインストールしておいて下さい! )

また、Vite には import 時に ?worker をファイル名の後ろに付けることで、import 先のファイルを Worker として扱えるようにしてくれます。

フロントエンド側が実装できたら、次は Web Worker を実装して行きましょう 👉

Web Worker を実装する

まずは TypeScript から JavaScript に変換するための処理(トランスパイル)を書きます。

トランスパイルには SWC が Web 環境でも使えるように @swc/wasm-web というパッケージを配信してくれているので、そちらを使って Web Worker の実装をします 👇

./src/workers/swc-worker.ts
import initSwc, { transformSync } from "https://esm.sh/@swc/[email protected]";

export const transpile = async (tsCode: string): Promise<string> => {
  await initSwc();

  const result = transformSync(tsCode, {
    jsc: {
      parser: {
        syntax: "typescript",
        tsx: false,
      },
    },
  });

  return result.code;
};

これで TypeScript のコードを JavaScript にトランスパイルできます。

次は Quick.js を使って JavaScript を実行するための evalCode() を実装しましょう 👇

./src/workers/quickjs-worker.ts
import QuickJSVariant from "https://esm.sh/@jitl/[email protected]";
import { newQuickJSWASMModuleFromVariant } from "https://esm.sh/[email protected]";

const QuickJSPromise = newQuickJSWASMModuleFromVariant(QuickJSVariant);

export const evalCode = (jsCode: string): Promise<string> => {
  const QuickJS = await QuickJSPromise;

  using vm = QuickJS.newContext();

  // ここでソースコードを実行する
  using vmResult = vm.evalCode(result.code);

  if (vmResult.error) {
    const error = vm.dump(vmResult.error);
    const errorObj = new Error(error.message);

    errorObj.name = error.name;
    errorObj.stack = error.stack;

    throw errorObj;
  }

  // `globalThis.result`に格納されている値を文字列として取り出す
  const result = vm.getProp(vm.global, "result").consume(vm.getString);

  return result;
}

ここでは using 宣言を使って実装しています。これは TypeScript 5.2 で実装された機能で、メモリの解放処理などをスコープが外れた時に自動的に行ってくれる機能となっています。

Quick.js ではメモリ解放を自動的に行ってくれないので、using 宣言を使うことで .dispose() を実行しなければいけない所を書かなくてもいいようにしています。( この辺りは実際に Quick.js を JS で実装してみるとその便利さが実感できると思います )

上記の実装ができたら、あとはフロントエンド側で使えるように Comlink を使って API を expose します 👇

./src/workers/mod.ts
import * as Comlink from "https://esm.sh/[email protected]";
import { transpile } from "./swc-worker.ts";
import { evalCode } from "./quickjs-worker.ts";

const apis = {
  eval: async (tsCode: string): Promise<string> => {
    const jsCode = await transpile(tsCode);
    const result = await evalCode(jsCode);
    return result;
  },
};

Comlink.expose(apis);

これで実装は完了です!次は実際に動作させてみましょう!

動作確認

<textarea /> に TypeScript のコードを入力して「実行する」ボタンを押すと、実行結果が alert() で表示されていれば OK 👌 です。

あとがき

以上で Quick.js と SWC を活用して Web Worker 上に TypeScript のサンドボックス環境を実装する方法を紹介しました。

Quick.js を Web Worker で実行するという中々ニッチなアプローチですが、思っているよりも簡単に実装できるので、「 活用できそう! 」と思った方はぜひ参考にして見てください!

まあ、制約が多すぎて使いこなすの大変なんですけどね

はい、ここまで読んでくれてありがとうございました!
これが誰かの参考になれば幸いです。
記事に間違いなどがあれば、SNSなどで教えて頂けると嬉しいです。

それではまた 👋