🦝

ブラウザ上でTypeScriptでツールを作成・実行できるサービス「Moyuk」を支える技術

2023/04/10に公開
3

一年以上温めに温めまくった個人開発サービス Moyuk を Product Hunt でローンチしたので、技術的な知見を書きます🚀

https://www.producthunt.com/posts/moyuk-beta

About Me

作ったもの

https://moyukapp.com/?ref=zenn

説明

詳しい説明:

サービスの詳しい紹介はこちらに書いたので読んでみてください 🙏

https://note.com/kohii/n/nb4a696cd73c1

雑な説明:

Moyuk は TypeScript で書いた関数を、ブラウザ上で実行、管理、共有できる Web アプリ(”App”)に変換するプラットフォームです。

技術的要素の概要

Moyuk には一般的なアプリケーション(データの出し入れやUIの描画など)としての要素に加えて、以下のような特徴的な要素があります。

  • ユーザーが書いた TypeScript を解析、トランスパイルするコンポーネント
  • トランスパイルされたコードを実行するためのサンドボックス化されたJS実行環境

後ほどそれぞれについて技術的なナレッジを書きます。

主な技術スタック

基本的にはすべて TypeScript で書かれていてモノレポとして構成しています。

構成

  • Next.js を土台としたアプリケーションが中心
    • Vercel にデプロイされ、DB・認証・ストレージとして Supabase を使用
  • ユーザーが書いたコードを実行するためのサンドボックス化されたJS実行環境がブラウザ内で動く
    • サンドボックス用のコンテンツは Cloudflare からサーブされる

一般的なアプリケーションとしての Moyuk

Next.js を土台としており、Vercel にデプロイしています。

Features directory

Bulletproof React を参考にして features ディレクトリ を採用しているのが一つの特徴です。機能的凝集度の高いまとまりを近い場所に置くのが個人的な好みなのでこの形にしています。Next.js の pages ディレクトリ内にはあまりロジックを書かず、features から export された部品を組み合わせ使うだけにしています。

状態管理

いろいろ検討しましたが、グローバルな状態管理を行うためのライブラリは使っていません。

最初はライトウェイトな状態管理ライブラリとして zustand を入れていましたが、React の Context で十分と判断し置き換えました。グローバルな状態を最小限に抑える方針を採っており、そんなにペインがないためです。

基本的にはサーバーから取得したデータは React Query のクライアントが管理します。その他のデータの状態は、適切なコンポーネントもしくは hooks 内で扱っています。

どちらのデータも大量のプロパティをフラットに保持するのではなく、ネストしたオブジェクトとして構造化していて、この構造はコンポーネントの階層の構造とわりと一致するような感じになっています。

バックエンド

DB は Supabase、ORM には Prisma、フロントエンド - バックエンド間でのデータのやりとりは tRPC を使っています。tRPC のエンドポイントは Next.js の API Route に乗っかっており、Vercel の Serverless Functions にデプロイされます。Supabase はマネージドな PostgreSQL として使っています。

Supabase は "Firebase alternative" な BaaS であり、クライアントライブラリを使ってフロントエンドからデータを直接読み書きすることもできます。最初はこの仕組みを使っていましたが、ちょっと難しいことをしようとすると、ストアドプロシージャにドメインロジックを書いたり、RLS の設定が複雑になってきたりして耐えられなくなり、tRPC + Prisma に移行しました。

tRPC + Prisma の組み合わせは開発者体験が良くオススメです。

UI

コンポーネントライブラリとして MUI を使っています。とにかく早く作りたかったので、MUI のコンポーネントの充実っぷりには助けられました。

フォームはみんな大好き React Hook Form + Zod を使っています。

ページのレンダリング

Next.js にはいろいろなレンダリング方法があります。パフォーマンスや Web Vitals のスコアに直結するのでとても重要です。

基本方針

Moyuk での方針はこんな感じです。

内容が変化しないページ(LP など) → SG

ビルド時に静的に生成します。CDN にキャッシュされるのでとても速いです。

認証が必要な動的なページ(ダッシュボードなど) → CSR

内容がほぼ空の静的なページをサーブし、データはクライアントサイドから取得します。

認証が不要で動的なページ(App やプロフィールなど) → ISR

サーバーサイドでページを生成(& CDN にキャッシュ)し、内容が古くなったら次にリクエストがあったときにバックグラウンドで再度生成します。
ページのデータが更新された場合は、On-demand revalidation の方法でキャッシュを明示的に purge します。
それでもタイミングによっては古い内容のキャッシュが配信される場合があるので、必要に応じてクライアントサイドから最新のデータを取り直しています。

tRPC とレンダリング

tRPC には SSR 機能 があり、これを使うと自動的にすべてのページが SSR されるようになります。最初は何も考えずにこれを使っていましたが、パフォーマンスの問題があり使うのをやめました。代わりに Server-Side Helpers を使って必要なデータを明示的にプリフェッチしています。

ユーザー自身が Private / Public にするかを選べるページのレンダリング

ユーザーは作成した App を public にするか priavte にするかを選ぶことができます。すべての App ページを何も考えずに ISR してしまうと、CDN 上にプライベートな App のページのキャッシュが生成されてしまいます。かと言ってプライベートな App ページを404(notFount: true)にしてしまうと、App のオーナーに対しても404を返してしまいます。

Moyuk でこれをどう解決しているかというと、getStaticProps 内で App のデータを取得し、それが public な App であったらそのデータを使って普通にレンダリングしますが、private な App(もしくは存在しない App)の場合にはコンテンツが空の状態のページをレンダリングします。

その後、ユーザーがそのページにアクセスしたときに、クライアントサイドから App のデータを改めてフェッチします。そのページを見ている人が App の持ち主であれば正常にデータを取得できるはずなのでそのまま続きをレンダリングします。持ち主でなければデータを取得できないので Not Found ページを描画します。

(追記)

これは理想的な方法ではありません。クライアント側からデータを取得してみるまではprivateなページなのか存在しないページなのか不明なため、サーバーからは常にステータスコード200を返してしまうというデメリットがあり、それを受け入れています

パフォーマンスチューニング

とても苦労したので、別に記事を書きたいと思います。

ユーザーが書いた TypeScript から App を生成する

↓はApp 編集画面の UI です。TypeScript でコードを書くと、右側に App のプレビューが表示されます。

ユーザーが書いた TypeScript は、主に2つのルートで処理され App のプレビューが生成されます。

1. TypeScript の関数の型情報を抽出する

Moyuk では export default された関数の型情報から、フォームを自動生成します。

型情報の抽出は TypeScript Compiler API を使って頑張っています。ドキュメントや記事が少ないのでとても大変でした…。

役立ったもの

  • TypeScript Deep Dive - Scanner、Checker、Binderなど、内部の概念を理解するのに役立ちました
  • Using the Compiler API - 具体的な使い方のサンプルコードが載っているので、各APIの用途を推測するのに役立ちました
  • TypeScript AST Viewer - Compiler API の内部的な概念や挙動を推測するのに役立ちました

Deno 互換な import 文のサポート

Moyuk では Deno 互換な import 文を使うことができます。

import { format } from "https://deno.land/std@0.181.0/fmt/duration.ts";
import { encode } from "npm:js-base64@^3.7";

TypeScript Compiler そのものにはリモートのモジュールを自動で解決する仕組みがないため、普通にコンパイルするとエラーになります。これをどうやって解決しているか説明します。

https

まず、https:// で始まる URL のインポートをどうやって解決しているかとういうと、TS Compiler API を呼び出す前に、コード内に記述されているすべての import 文の URL を抽出し、参照先のファイルをダウンロードしておきます。

  • ダウンロードしたファイルから参照されるファイルも芋づる式にすべてダウンロードします。
  • 参照先が X-TypeScript-Type ヘッダー をサポートする CDN (esm.sh や Skypack など) の場合、 .d.ts ファイルをダウンロードできるのでそっちをダウンロードします。

その後実際にコンパイラを呼び出すのですが、コンパイラにちょっと細工を加えておきます。TS Compiler API では CompilerHost というコンポーネントがモジュール名に対して参照先のファイルを解決する役割を担うので、これをカスタマイズして、URL 形式のモジュール名に対してダウンロードしておいたファイルが解決されるようにします。

npm

npm: という接頭語(npm: specifiers)を使用して、npm パッケージをインポートすることができます。

Moyuk は npm パッケージを esm.sh という CDN からダウンロードします。esm.sh は npm パッケージを ES Module の形式でビルドして配布してくれるので、ブラウザのような web-standard な JS ランタイムで npm パッケージを使うことができます。

Moyuk では npm: で始まる import 文があった場合に、以下のように esm.sh の URL に解決して、あとは上述の https の場合の手順と同様に扱っています。

npm:js-base64@^3.7https://esm.sh/js-base64@^3.7

2. TypeScript を JavaScript にトランスパイルする

ユーザーが書いた TS はブラウザ上で実際に実行されるので、実行可能な JS にトランスパイルしておきます。import 文で外部のモジュールを参照している場合は、そのモジュールも含めて単一の JS ファイルにバンドルします。

内部では esbuild を使っています。esbuild にはプラグインシステムがあり、npm:https:// のような import 文を解決するためのプラグインを自作しています。

ちなみに、App 編集時のプレビューでは、これらの処理は Web worker 内で実行し、App を Publish するときにはバックエンド(Vercel の Serverless Functions)で実行します。

ユーザーが書いたコードを安全に実行する

Moyuk はユーザーが書いたコードを実際に実行し、その結果を得る必要があります。
JS でコードを動的に実行するための方法として有名なのは eval ですが、これは安全ではありません
App の作成者が悪意のあるコードを書く可能性も考慮し、隔離された安全な環境でコードを実行する必要があります。

サンドボックス化されたJSランタイムの技術選定

かなりいろいろ調査しましたが、中にはセキュアじゃないものも多くあり判断が大変でした。以下は調査した技術の中で使えそうなものの一覧です。

Name Client / Server 説明
QuickJS Client Fabrice Bellard さんによって開発された非常に軽量なJSエンジン。quickjs-emscripten と組み合わせて使えば WASM で動く。ちなみに Figma のプラグインはこれで動いている。 最初はこれを使おうとしていましたが、思った以上におもちゃ感が強く、利用を諦めました。
WebContainers Client StackBlitz によって開発された、ブラウザ上で動く Node.js。Sandpack に比べて Node.js をより忠実に再現している。 開発の終盤で出てたので検討候補に入っていませんでした。
Sandpack Client CodeSandbox によって開発された、ブラウザ上で動く Node.js。WebContainers に比べて多くのブラウザで動く。 これも開発の終盤で出てたので検討候補に入っていませんでした。
Deno Deploy Subhosting Server Deno Deploy 関連の新サービス。ユーザーが入力した JS/TS を Edge 上で安全に実行するために用意された環境。 開発の中盤で出てきましたが、最近まで存在に気づきませんでした…。まだ Private Beta ですが、料金次第では真っ先に検討したい候補です。
ShadowRealm Client 別の Realms (global scope みたいなもの)を作る API 制限が多いのと、まだ策定段階(Stage 3)のため却下
自作ランタイム Server 自分でサーバーを立て、Deno / Node.js などでサンドボックスを自作する。 お金が掛かりそう&管理が大変そうなので却下。
iframe sandbox Client iframe に sandbox 属性をつけるとサンドボックス化される。 後述

最終的には、iframe sandbox と web worker と Content Security Policy (CSP) を組み合わせる方法を採用しました。
iframe が環境をサンドボックス化し、CSP が外界との接触を制限し、web worker がコードを実行します。
(わかる人向けの説明: ゴンが止め!!ヒソカが覆い!!キルアが支える!! みたいな感じです。)

iframe

まず、iframe を動的に作成します。iframe には sandbox 属性をつけておき、iframe に読み込むコンテンツは moyukapp.com とは別ドメイン(別オリジン)から配信します。こうすることによって iframe の内側から外側へ一切アクセスできなくなります。ちなみに CodeSandbox も iframe を使っていますが、Moyuk は CodeSandbox よりかなり厳しい設定になっています。

const iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");
iframe.style.display = "none";
iframe.src = sandboxUrl;
document.body.appendChild(iframe);

なお、sandbox 属性に allow-scripts, allow-same-origin をつけて、iframe 内のコンテンツを同一オリジンから取得するとサンドボックス化が破られるので危険です。

Web worker と CSP

次に、iframe 内で Web worker を立ち上げます。Web worker にロードされる JS には、レスポンスヘッダで次のような Content-Security-Policy (CSP) を指定しています。雑に説明すると CSP はブラウザ上で実行できるスクリプトの取得元や、ネットワークアクセスの通信先を制限するセキュリティレイヤーです。

Content-Security-Policy: default-src 'none'; script-src blob:

こうすることで、Web worker にロードされたスクリプトは外部へのネットワークアクセスや eval の実行などが一切できなくなります。script-src blob: はユーザーが書いたコードを読み込むために使います(後述)。

なお、Moyuk ではネットワークアクセスはユーザーが明示的に許可することができます。(Cloudflare Workers を使い、CSP を動的に書き換えています。)

ユーザーが書いたコードを読み込む

ユーザーが書いたコード(を JS にトランスパイルしたもの)を上述の Web worker 内にロードします。

Moyuk 本体から、iframe にコードを渡し、iframe はそれを Web worker に受け渡します。Web worker は受け取ったコードを blob: URL に変換し、dynamic import します。dynamic import の戻り値として、ユーザーが書いたスクリプトから export されているメンバーを取得できます。

class ExecutionContext {
  async importSourceCode(code: string) {
    const url = URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
    this.exportedMembers = await import(url);
  }

  ...
}

先程 CSP で script-src blob: を指定していたのは :blob URL からスクリプトをインポートできるようにするためです。blob: URL でインポートされたスクリプトは、親(Web worker)の CSP を受け継ぐので、外部へのネットワークアクセスや eval の実行などができません。

なお Firefox では、Web worker 内での dynamic import がまだできないため、Moyuk としては非対応ブラウザになっています。

ユーザーが書いた関数を実行する

App の UI 上で入力された値を引数にして対象の関数を実行します。だいたいこんな感じのことをやっています。

class ExecutionContext {
  ...

  async callFunction(functionName: string, args: unknown[]) {
    const f = this.exportedMembers[functionName];
    if (f && typeof f === "function") {
      const returnValue = f(...args);
      const value = await Promise.resolve(returnValue);
      return {
        type: "SUCCESS",
        value,
      };
    }
    return {
      type: "FUNCTION_NOT_FOUND",
    };
  }
}

前の手順で dynamic import したコードから export されているメンバーから実行対象の関数を探し、引数を与えて実行します。実行結果が Promise の場合は await して結果を返します。

おわりに

最後まで読んでいただきありがとうございました🙇‍♂️
部分的な紹介・説明になってしまっているので、ここどうなってるの?とかあればお答えするのでお気軽に聞いてください。
個人開発の回顧録や教訓などについてもそのうち書こうと思っています。

そして、Moyuk をぜひ使ってみてください🙏 フィードバック・リクエスト・質問大歓迎です:

Product Hunt でも Upvote、コメントなど頂けると励みになります🙏

https://twitter.com/kohii00/status/1645326733498019840

Resources

日本語での紹介記事:
https://note.com/kohii/n/nb4a696cd73c1

英語での紹介記事:
https://medium.com/@kohii/7067ca513936

Moyuk のドキュメント:
https://docs.moyukapp.com/

開発予定の機能:
https://github.com/orgs/moyukapp/projects/1

Discussion

ナルミンチョナルミンチョ

private な App(もしくは存在しない App)の場合にはコンテンツが空の状態のページをレンダリングします。

HTTP ステータスコード表

Cookieなどを使う実装 存在すると嘘をついてしまうかもパターン 存在しないと嘘をついてしまうかもパターン
public 200 200 200
priavte 200/404 200 404
存在しない 404 200 404

Moyuk Beta を登録して試したところ, 「存在すると嘘をついてしまうかもパターン」で実装していますね。
この場合 Google 検索のインデックスに priavteと存在しないページがいつまでも残ってしまうような気がします.

「存在しないと嘘をついてしまうかもパターン」(App のオーナーに対しても404だけれどもAppが表示される) が良いかなと思いましたが、「存在しないと嘘をついてしまうかもパターン」には欠点がありますか?

kohiikohii

コメントありがとうございます!
また丁寧に整理していただきありがとうございます🙏

ご指摘の通り、常に200になるというデメリットがあり、それを受け入れています。
なぜこのようにしているかというと、

  • 「存在しないと嘘をついてしまうかもパターン」を Next.js の ISR で実現するのは難しそうだったため
  • もっとベターな方法を検討するのに時間がかかりそうだったため
  • サービスに引きがあるかどうかわからない状態でSEOのことを考えるより、リリースを優先したかったため

なお、Robots meta タグでインデックスされないようにはしておきました。

このトレードオフの判断を記事に書かなかったのはちょっと良くなかったなあと思うので後ほど修正します🙇‍♂️

(自分の理解が正しければ)「存在しないと嘘をついてしまうかもパターン」について、思いつくデメリットは以下の2つくらいでしょうか。

  • 404.tsx から App ページのコンポーネントを呼び出すことになるが、かなりトリッキー
  • 404.tsx の中で、App ページのパスに該当するかどうかの判定し slug 等を抽出するロジックを自前で書く必要がある

これは Next.js でなければもっと簡単かもしれません...。

ちなみに「Cookieなどを使う実装」はどのようなものか教えていただけないでしょうか🙏

ナルミンチョナルミンチョ

調べたところ Next.js では404.tsxを使わずに 単純にステータスコード 404 を返す はできない仕様みたいですね.

たしかに「存在すると嘘をついてしまうかもパターン」を使うのがベストですね.
欠点のGoogle 検索のインデックス残る問題は Robots meta タグでの対策で充分ですね.

「Cookieなどを使う実装」は この zenn.dev と同じように, クライアントから送られてきた 「認証情報が含まれる Cookie」 かもしくは「TCPの層で直近に接続したクライアントかの区別ができる?方法」
を使って ページをレスポンスを分ける方法です. ISR は使えないかもしれません.