🐞

バグってはいけない2021というサービスを作った

2022/01/01に公開

とある執筆者の集まるSlackで出た馬鹿話を元に、思いつきだけで、ちょっとしたサービスを作ってみました。

酒飲みながらコーディングとか執筆したりする、酒もくもく会をやりたいお気持ち表明(もちろんノンアルコール・ソフドリもうぇるかむ)

泥酔ハッカソンの機運

コンパイルエラーとかバグだしたらショット一杯w

バグってはいけない2021

仕様としては MonacoEditor(VSCodeの中の人)を使ってTypeScriptのコードを入力し動かすと、お題に沿っているかをブラウザ上のSandboxで実行して判定して、成功したらOK!なものです。

競技コーディングとかその手のやつの簡易版と思っていただければわかりやすそうですが、それらと違うのは、普通の競技コーディングはサーバー上でコードを動かすのに対して、コードはすべてブラウザ上で動くという点です。

使っている技術は

  • React on Vite
  • React Location
  • TailwindCSS
    • ChakraUI で良かったのでは?と思ったんだけどついつい
  • Monaco Editor
    • vite-plugin-monaco-editor
  • 自作Sandbox
    • Web Worker
  • Firebase
    • authentication
    • Realtime Database

です。

モチベーション

若手のウェブエンジニアの子がロジックを組み立てるのに不安があるようだったので、簡単な問題集みたいなのがあれば良いのではないか?と常々思っていたところでした。

あとは、年末で忙しすぎてあまりにもストレスが貯まってたので鬱憤晴らしにコード書こうと思い、馬鹿話のタイミングで作った感じです。

結局はやらなかったんですが、年末最終日に有給をとりつつ、なぜか会社で酒飲みながらコーディングチャレンジしたら面白いのでは???みたいな頭の悪いことを考えていたのもあります(結局はなしは流れた)

というか、もうちょっと早いタイミングで思いついていたら、クソアプリのカレンダー | Advent Calendar 2021 - Qiita 向けにちょうど良いネタだったのでは?感があります。

というか記事の公開、2021年中にできなかったし……。

名前の理由

まぁ、年末の某有名番組をもじった名前なんですけど、わざわざ 2021 ってつけた理由は、真面目にやるなら作り直したいなーというだけの話です。

  • Sandbox 作ってみたけど、本当に安全なのか分からない
  • というかそもそもユーザーサイドのsandboxだと仕組み上どうしても限界がある

なので、作り直したときにはきっと別の名前になっているはずです。

ロゴ

@mottox2神がジョバンニしてくれました。

https://github.com/erukiti/dont-bug-2021/blob/main/src/dont-bug-2021.svg

サービスの説明

https://dont-bug-2021.web.app/

アクセスしたら Google でサインインしてください。

トップ画面

「問題作成」をクリック。

問題作成画面

「出題文」を Markdown で記述し、その出題を満たすための TypeScript のコードを「テスト用コード」に入力します。

USER_INPUT_CODE は、ユーザーが入力したコードがそのまま置換されます。
expect は今のところ toBe のみ利用できます。

このサンプルだと足し算をテストするためのコードなので、add(1, 2) の結果は 3 になりますし add(-10, 10) の結果は 0 になります。

これらを入力し終わったら save ボタンを押すと、問題が DB に保存されてチャレンジページの URL が発行されます。

https://dont-bug-2021.web.app/9RaPTLTyKe8U01A6

チャレンジ画面

問題文を満たすためのコードを入力して Run ボタンを押すと、判定されます。

function add(a, b) {
  return a + b;
}

Run

正解だったので Success と表示されます。他にこの問題に取り組んでいる人がいたら、その人のステータスも表示されます。

たとえば、別アカウントでアクセスして失敗してみたとします。

別アカウントで失敗してみた

Testing Failed 0/2 が赤い枠の中にエラーとして表示されて、
元ウィンドウでは新しいアイコンと Testing Failed が表示されています。

これが構文エラーなどの場合は、

構文エラー

Compile Error になり、問題のある部分を赤枠の中で指摘してくれます。

という感じのサービス(アプリ)でした。

想定

問題を作成して仲間内で URL を共有してチャレンジするようなユースケースを想定しています。
セキュリティ的には何かしら問題があるかもしれないので、限界チャレンジはしないでください。
また、このサイトは一定期間で閉じる予定です。

ここからは技術を説明します。

React on Vite

Vite(ヴィート)について、ずっと気にはなっていたので使ってみました。

vite.config.ts には、通常手順でインストールされる @vitejs/plugin-react だけではなく vite-plugin-monaco-editor を組み込んで、ついでに、パスエイリアスを設定しています。

import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import monacoEditorPlugin from "vite-plugin-monaco-editor";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), monacoEditorPlugin()],
  resolve: { alias: { "~": path.resolve(__dirname, "./src") } },
});

パスエイリアスは tsconfig.json でも同様に設定しています。

  "compilerOptions": {
    ...
    "baseUrl": ".",
    "paths": {
      "~/*": ["./src/*"]
    }
  }

感想

お手軽でとても良いと思います。ただ、ガチで開発しようとすると、結局 prettier だの eslint だの .editorconfig だの他あれこれセットアップする手間はかかるんですよね……。

真面目に開発するなら素直に最初から Next.js 使えばいいやというのが僕の結論ですが、ちょっと遊ぶには React on Vite も悪くないなーと思いました。少なくともCRAよりは遙かにマシかなという感想です。まぁ双方競い合ったりして、より良くなる未来も来るでしょう。

プラグインとかでロックインされすぎるのもどうなのかなーという気持ちもありますが、手早く何かを作るための道具として、Vite 使い慣れていると良いかもと思いました。

vite-plugin-monaco-editor

React + MonacoEditor を使うとき、大体 Webpack が難所になりますが、Vite だとプラグイン一発で使えるのは良き DX ですね。プラグインを組み込んだ状態で、普通に import * as monaco from "monaco-editor" すれば使えます。

個人的にはあまりプラグインとかにロックインされるのは好みではないんですが、MonacoEditorの環境構築はあまりにも面倒くさくDXを下げるので、まぁ致し方ないというところでしょう。

ただ、vite-plugin-monaco-editor は MonacoEditor を組み込むところまでしか面倒を見てくれないので、実際に MonacoEditor を React で使うためには、いい感じに Hooks でエディタのマウントなどを記述することになります。

MonacoEditor でいつも苦労するのは明示的に height を指定しないといけない点ですかね。雑に作るなら決め打ちにしちゃいますし、真面目にやるときは計算しなきゃいけないしで、どうするのがベストなんだろう?っていう心のコストがかかりがちです。

React Location

React Router にはいい思い出が無いのと、せっかくなので React Location を試してみようと思い試してみました。

とはいえ、今回やった程度の範囲では、概念的に違いが出るほどでもなかったので、ふーんって感じでした。

公式を見て書けばそう詰まるところもないかなという感じなので省略します。

設定をガシガシ頑張れば React Router よりも便利そうなんですが、その路線いくよりは、素直に Suspense 使った方がいいのでは?という気持ちもあるので、使い勝手に関しては判断を保留したいところです。

TailwindCSS

元々 PostCSS に対応してるようですし、Viteの公式を読めば TailwindCSS の組み込み方に苦労することもないでしょう。

postcss.config.jstailwind.config.js を書いて src/index.css に Tailwind 用の import を書くだけです。

@tailwind base;
@tailwind components;
@tailwind utilities;

自作Sandbox

今回のキモはこれでしょうか。

意外に苦労しました。ウェブブラウザで動くSandboxなんて随所にあるんだし、ノウハウも完全に固まって、決定的な npm ライブラリでもあるんじゃないの?と思っていた時期もありました。

探せば npm ライブラリや GitHub 上のコードはあるんですが、大半が10年前に作られたものばかりでした。

仕方ないのでそれっぽい検索ワードを探しながら調べていくと大体二つの方向性で実現されているようです。

  1. IFrame
  2. Web Worker

IFrame もあれこれオプションを設定すればセキュアな Sandbox として使えるっぽいんですが、同じことはどうやら Web Worker でもできるっぽので、結局 Web Worker を使いました。

今回参考にしたのは https://stackoverflow.com/questions/195149/is-it-possible-to-sandbox-javascript-running-in-the-browser/37154736#37154736 です。

  1. Worker でコードの母体を URL として生成する
  2. 1で生成した URL を worker として起動
  3. worker のイベントハンドラを登録してから worker に postMessage で動かしたい対象のコードを投げつける
  4. イベントハンドラ経由で実行結果が返ってくる

という流れです。

Workerで動くコードの母体は、

  1. ホワイトリスト方式でグローバルオブジェクトを最小限にまで削除していく
  2. 待避した addEventListener / postMessage を使って、worker の外からメッセージとして飛んでくる、対象コードを実行し、その結果を返す

というものです。

ホワイトリストでは、動作するのに最小限必要なものだけ素通しにする感じです。場合によっては、動作をリプレイスしたものを登録しなおすべきかもしれませんが、凝ったことをすればするほど脆弱になりかねないです。

少しだけ工夫があるとすれば、動かす対象のコードを function().toString() でテキスト化している点でしょうか。

ここらへんこったメタプログラミングをいくらでもしようがあるところかもしれません。bundle したコードを動かすなんてのも用途としては普通にあるはずです。

const runner = () => {
  const s = function () {
    const _postMessage = postMessage;
    const _addEventListener = addEventListener;
    const _eval = eval;

    const results: boolean[] = [];
    const expect = <T = any>(received: T) => {
      return {
        toBe: (expected: T) => {
          try {
            results.push(received === expected);
          } catch (err) {
            results.push(false);
          }
        },
      };
    };

    (function (obj) {
      let current = obj;
      const keepProperties = [
        // Required
        "Object",
        "Function",
        "Infinity",
        "NaN",
        "undefined",
        "caches",
        "TEMPORARY",
        "PERSISTENT",
        // Optional, but trivial to get back
        "Array",
        "Boolean",
        "Number",
        "String",
        "Symbol",
        "Map",
        "Math",
        "Set",
        "JSON",
        "Date",
        "RegExp",
        "BigInt",
        "Intl",
        // "Error",
        // "Generator",
        // "GeneratorFunction",
        // "AsyncFunction",
        // "AsyncGenerator",
        // "AsyncGeneratorFunction",
        "encodeURI",
        "encodeURIComponent",
        "decodeURI",
        "decodeURIComponent",
      ];

      do {
        Object.getOwnPropertyNames(current).forEach(function (name) {
          if (keepProperties.indexOf(name) === -1) {
            // console.log(name);
            delete current[name];
          }
        });

        current = Object.getPrototypeOf(current);
      } while (current !== Object.prototype);
      current["expect"] = expect;
      // @ts-ignore
    })(this);

    _addEventListener("message", function (e) {
      // var f = new Function("", "return (" + e.data + "\n);");
      try {
        _eval(e.data);
        _postMessage(results);
      } catch (err: any) {
        if ("message" in err) {
          _postMessage(err.message);
        }
      }
    });
  }.toString();

  return s;
};

export function safeEval(
  untrustedCode: string,
  timeout: number = 1000
): Promise<boolean[] | string> {
  return new Promise(function (resolve, reject) {
    var blobURL = URL.createObjectURL(
      new Blob([`(${runner()})()`], { type: "application/javascript" })
    );

    var worker = new Worker(blobURL);
    URL.revokeObjectURL(blobURL);

    worker.onmessage = function (evt) {
      worker.terminate();
      resolve(evt.data);
    };

    worker.onerror = function (evt) {
      reject(new Error(evt.message));
    };

    worker.postMessage(untrustedCode);

    setTimeout(function () {
      worker.terminate();
      reject(new Error("The worker timed out."));
    }, timeout);
  });
}

Firebase

Firebase の Authentication と Realtime Database と hosting を使っていますが、特に特殊なことはしていません。公式サイトに書いてあるようなことしかしてないです。

ただ最近 Firebase を触っていなかったせいか Firebase SDK が v9 になっていて少し困惑しました。使い勝手が良くなってるのはいいんですが、ぐぐって出てくるコードがことごとくマッチしない、みたいな状況だったので、割とめんどくさいなーという気持ちとの戦いでした。

でも、今から Firebase 触るなら素直に v9 で書いた方がいいと思います。

まとめ

「バグってはいけない2021」というサービスを

  • React on Vite
  • React Location
  • TailwindCSS
    • ChakraUI で良かったのでは?と思ったんだけどついつい
  • Monaco Editor
    • vite-plugin-monaco-editor
  • 自作Sandbox
    • Web Worker
  • Firebase
    • authentication
    • Realtime Database

で作ってみました。

ソースコードは https://github.com/erukiti/dont-bug-2021 で公開しているので、個々の技術で気になるところがあれば、コードを読んでいただければと思います。

セキュリティ的に不安が大きいのと、クライアント側で実行する限りどう頑張ってもシステムの公平性を保つのは無理なので、いずれ気が向いた頃にがっつり作り直す予定です。

サービスに関しては、セキュリティ的に不安を抱えているのもあるので期限をつけて公開しています。

感想

ストレス発散に趣味プロは良いですね!

Discussion