😁

えっ?Browser内でNode.jsアプリが動く?? WebContainerAPIをTypeScriptで動かしてみた

2023/02/17に公開

概要

StackBlitzのサービスを支えるWebContainerという素晴らしい技術のAPIが公開されたので、実際に気になっている方に向けて、体験した所感を紹介しようと思います。一見サーバサイドのNode.jsでやっているように見えることが、実際にはブラウザ内部で動いているので、めちゃくちゃすごいです。

対象読者

  • StackBlitzにお世話になっていて、その裏側の仕組に興味がある方
  • ブラウザ内で、Node.jsを動かしたいなぁと思っている方
  • WebContainerに興味がある方

はじめに

フロントエンド界隈では、有名なplayground環境と思われるStackBlitz(このZennの挿入もできるサービスなので、見たことある方も多いかもしれません)が提供している、サービスを支える重要な技術である、WebContainer(Webブラウザ上で実現されるWebAssemblyベースのNode.js環境)のAPIが公開されました!!
https://blog.stackblitz.com/posts/webcontainer-api-is-here/
https://www.publickey1.jp/blog/23/webwebassemblynodejswebcontainerapihttpnodejs_cli.html

サーバサイドではなく、Browser内でNode.jsが動く技術で、npmを使って、packageをインストールしたり、それを使ってアプリを動かしたり、今までサーバで実現していたことがBrowser内でできて、それがAPIとして公開されたので夢が広がりますね!!
例えば、クラウドIDEのように、WebSocketを使ったインタラクティブなWebアプリを作りたい、でも、ひとりひとりに専有してもらうためのDocker Containerを割り当てるのは、コストパフォーマンス的に難しい。。。みたいなケースでは、画期的な選択肢の一つになりえるかと思います。

今回は、公開されているtutorialをベースに、本家のtutorialでは、VanillaJsでExpressを使ったサンプルなので、ここでは、モダンに、TypeScript、fastifyで動かしていきたいと思います。

それではやってみよう

STEP1

参考:tutorial step1

まずは、WebContainerを利用するための雛型を作成していきましょう。
今回使っているのNode.jsバージョンは以下のとおりです。

node --version
# -> v19.5.0
npm --version
# -> 9.3.1

まずは、以下のコマンドで雛形を作りましょう

# npmのバージョン7+では、 ' -- ' が必要です。古いバージョンの場合は不要です。
npm create vite@latest webcontainers-fastify-app -- --template vanilla-ts

次に、作成された雛形を元に、packageをinstallして、とりあえず起動してみましょう。

cd webcontainers-fastify-app/
npm install
npm run dev

以下のように表示されれば、成功ですので、http://localhost:5173にアクセスしてみましょう。

VITE v4.1.1  ready in 187 ms
➜  Local:   http://localhost:5173/
➜  Network: use --host to expose
➜  press h to show help

ブラウザに以下のような表示がされれば成功です。

今画面に見えているのが、src/main.tsなので、ここを書き換えながらWebContainerを動かしてみます。
まずは、WebContainerを使う前準備として、自動生成された不要なものを削除して、ミニマルな状態にしていきましょう。

src/main.ts
import './style.css';

document.querySelector('#app')!.innerHTML = ``;

自動生成された不要なファイルは削除してしまいましょう。

rm src/counter.ts
rm src/typescript.svg

チュートリアルの成果物としては、左側にtext-area、右側にWebContainerからの情報を表示するため、WebContainerが準備ができるまでの簡易的なローディングページを作ります

Installing dependencies...
src/main.ts
import './style.css';
document.querySelector('#app').innerHTML = `
  <!-- ここを追記しました -->
  <div class="container">
    <div class="editor">
      <textarea>I am a textarea</textarea>
    </div>
    <div class="preview">
      <iframe src="loading.html"></iframe>
    </div>
  </div>
`

また、最低限の見た目を調整しておくため、以下のstyle.cssを作成します。

src/style.css
* {
  box-sizing: border-box;
}

body {
  margin: 0;
  height: 100vh;
}

.container {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
  height: 100%;
  width: 100%;
}

textarea {
  width: 100%;
  height: 100%;
  resize: none;
  border-radius: 0.5rem;
  background: black;
  color: white;
  padding: 0.5rem 1rem;
}

iframe {
  height: 100%;
  width: 100%;
  border-radius: 0.5rem;
}

ブラウザの表示がこんな感じになればOKです。

Step2

https://webcontainers.io/tutorial/2-setting-up-webcontainers

Headerの設定

WebContainerでは、特定のヘッダーがついている場合だけ動くようなので、Viteの設定を変更して、ヘッダーを付与します。

vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    headers: {
      'Cross-Origin-Embedder-Policy': 'require-corp',
      'Cross-Origin-Opener-Policy': 'same-origin',
    },
  },
});

上ファイルを作成して、Viteを再起動しましょう。
また、画面側は、ブラウザのほうも、強制リロード(Cmd+Shift+r や Ctrl+Shift+rです)することで、ヘッダーが有効になります。

いよいよ、WebContainerのAPIをインストールしてみます。

WebContainer APIのインストールと利用

npm install @webcontainer/api

上記apiを利用するようにmain.tsを書き換えてみます。

src/main.ts
import './style.css';

document.querySelector('#app')!.innerHTML = `
  <div class="container">
    <div class="editor">
      <textarea>I am a textarea</textarea>
    </div>
    <div class="preview">
      <iframe src="loading.html"></iframe>
    </div>
  </div>
`
import { WebContainer } from '@webcontainer/api';

let webcontainerInstance:WebContainer;

window.addEventListener('load', async () => {
  // Call only once
  webcontainerInstance = await WebContainer.boot();
});

サーバー側(便宜上サーバーと呼びます)の実装

さて、いよいよ、WebContainerの醍醐味である、サーバー側(※サーバー側と言っていますが、実際にはBrowser内部で、WebContainerが動作し、そこで動くNode.jsのアプリを指しています)の実装を入れてみましょう。
本家tutorialでは、expressでしたので、ここではfastifyを動かしてみたいと思います。

まずは、クライアントサイドに調整をいれいます。WebContainer内で動く、Node.jsのアプリをクライアントサイドから書き換えられるように、WebContainer側のファイルを読み込んで表示させています。

src/main.ts
import './style.css';

document.querySelector('#app')!.innerHTML = `
  <div class="container">
    <div class="editor">
      <textarea>I am a textarea</textarea>
    </div>
    <div class="preview">
      <iframe src="loading.html"></iframe>
    </div>
  </div>
`
import { WebContainer } from '@webcontainer/api';
import { files } from './webContainerSideFiles';

let webcontainerInstance:WebContainer;

window.addEventListener('load', async () => {
  // Call only once
  webcontainerInstance = await WebContainer.boot();
  await webcontainerInstance.mount(files);

  const packageJSON = await webcontainerInstance.fs.readFile('package.json', 'utf-8');
  console.log(packageJSON);

  const textareaEl = document!.querySelector('textarea');
  if( textareaEl != null) {
    textareaEl.value = files['index.js'].file.contents;
  }
});

次に、WebContainer側で動くNodeJsのアプリを実装します。
これが、とても重要なファイルです。Node.js側は、index.jsとpackage.jsonから構成される、シンプルなwebアプリです。
FileSystemTreeという型に従って、実際のファイル名とファイルの中身を定義します。

src/webContainerSideFiles.ts
import { FileSystemTree } from "@webcontainer/api";

export const files:FileSystemTree = {
  'index.js': {
    file: {
      contents: `
import Fastify from 'fastify';
const fastify = Fastify({
  logger: true
});
      
fastify.get('/', async (request, reply) => {
  return 'Welcome to a WebContainers app! 🥳';
});
      
const start = async () => {
  try {
    await fastify.listen({ port: 3111 });
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
}
start();
`,
    },
  },
  'package.json': {
    file: {
      contents: `
{
  "name": "example-app",
  "type": "module",
  "dependencies": {
    "fastify": "latest",
    "nodemon": "latest"
  },
  "scripts": {
    "start": "nodemon index.js"
  }
}`,
    },
  },
};

Step3

WebContainer側のパッケージのインストールを行ってみる

webcontainerInstance.spawn('npm', ['install']);

このような形で、webcontainerInstance.spawnを利用することで、WebContainer側に命令を出すことができます。

とりあえず、WebContainer側でnpm installしてinstallが動いていそうなことを確認していましょう

src/main.ts
import './style.css';

document.querySelector('#app')!.innerHTML = `
  <div class="container">
    <div class="editor">
      <textarea>I am a textarea</textarea>
    </div>
    <div class="preview">
      <iframe src="loading.html"></iframe>
    </div>
  </div>
`
import { WebContainer } from '@webcontainer/api';
import { files } from './webContainerSideFiles';

let webcontainerInstance:WebContainer;

window.addEventListener('load', async () => {
  // Call only once
  webcontainerInstance = await WebContainer.boot();
  await webcontainerInstance.mount(files);

  const packageJSON = await webcontainerInstance.fs.readFile('package.json', 'utf-8');
  console.log(packageJSON);

  const installProcess = await webcontainerInstance.spawn('npm', ['install']);
  
  installProcess.output.pipeTo(new WritableStream({
    write(data) {
      console.log(data);
    }
  }));

  const textareaEl = document!.querySelector('textarea');
  if( textareaEl != null) {
    textareaEl.value = files['index.js'].file.contents;
  }
});

ブラウザのconsole.logに下記のように表示されればOKです。

Step4

WebContainer側のfastifyベースのwebアプリを起動できるようにしてみましょう。 WebContainer側にnpm run startの命令を出せるようにします。(startDevServer()とそれを呼び出す部分を追加しています)

src/main.ts
import './style.css';

document.querySelector('#app')!.innerHTML = `
  <div class="container">
    <div class="editor">
      <textarea>I am a textarea</textarea>
    </div>
    <div class="preview">
      <iframe src="loading.html"></iframe>
    </div>
  </div>
`
import { WebContainer } from '@webcontainer/api';
import { files } from './webContainerSideFiles';

let webcontainerInstance:WebContainer;

window.addEventListener('load', async () => {
  // Call only once
  webcontainerInstance = await WebContainer.boot();
  await webcontainerInstance.mount(files);

  const packageJSON = await webcontainerInstance.fs.readFile('package.json', 'utf-8');
  console.log(packageJSON);

  const installProcess = await webcontainerInstance.spawn('npm', ['install']);
  
  if (await installProcess.exit !== 0) {
    throw new Error('Installation failed');
  };

  const textareaEl = document!.querySelector('textarea');
  if( textareaEl != null) {
    textareaEl.value = files['index.js'].file.contents;
  }

  startDevServer();
});

const startDevServer = async () => {
  console.log('npm run start!!!');
  await webcontainerInstance.spawn('npm', ['run', 'start']);
  
  const iframeEl = document.querySelector('iframe');
  // Wait for `server-ready` event
  webcontainerInstance.on('server-ready', (_port, url) => {
    if(iframeEl!=null) {
      iframeEl.src = url;
    }
  });
}

以下のように、右側のiframeに、fastifyから返却された、「Welcome to a WebContainers app! 🥳」が表示されていればOKです。

Step5

ここまでで、WebContainerから返却されたメッセージをクライアント側で取得して、表示することができたので、それを少しインタラクティブにして、みましょう。
左側のTextAreaでWebContainer側のindex.jsを編集したら、それがWebContainer側に書き込まれて、WebContainer側からのメッセージを書き換えられるようにしてみます。

src/main.ts
import './style.css';

document.querySelector('#app')!.innerHTML = `
  <div class="container">
    <div class="editor">
      <textarea>I am a textarea</textarea>
    </div>
    <div class="preview">
      <iframe src="loading.html"></iframe>
    </div>
  </div>
`
import { WebContainer } from '@webcontainer/api';
import { files } from './webContainerSideFiles';

let webcontainerInstance:WebContainer;

window.addEventListener('load', async () => {
  // Call only once
  webcontainerInstance = await WebContainer.boot();
  await webcontainerInstance.mount(files);

  const packageJSON = await webcontainerInstance.fs.readFile('package.json', 'utf-8');
  console.log(packageJSON);

  const installProcess = await webcontainerInstance.spawn('npm', ['install']);
  
  if (await installProcess.exit !== 0) {
    throw new Error('Installation failed');
  };

  const textareaEl = document.querySelector('textarea') as HTMLTextAreaElement;
  if( textareaEl != null) {
    textareaEl.value = files['index.js'].file.contents;
    textareaEl.addEventListener('input', (_event) => {
      writeIndexJS(textareaEl.value);
    });
  }

  startDevServer();
});

const startDevServer = async () => {
  console.log('npm run start!!!');
  await webcontainerInstance.spawn('npm', ['run', 'start']);
  
  const iframeEl = document.querySelector('iframe');
  // Wait for `server-ready` event
  webcontainerInstance.on('server-ready', (_port, url) => {
    if(iframeEl!=null) {
      iframeEl.src = url;
    }
  });
}

const writeIndexJS = async (content:string) => {
  await webcontainerInstance.fs.writeFile('/index.js', content);
};

画面を表示して、TextArea側のメッセージを返却しているところを書き換えたら、WebContainer側のNodeJsの戻りを表示している右側のエリアに反映されたら成功です。

おわりに

ここまでで、一通りWebContainerのAPIを体験してみましたが、いかがでしたでしょうか?
ブラウザの中でNode.jsのアプリがうごいて、クライント側からNode.jsのアプリを書き換えられるなんてとってもわくわくしませんか?
いままでだったら、Docer Containerにさせていたことが、クライアント側で閉じてできることが増えるので、とても画期的です。私は、これを公開APIとしてくれることにとても感動しました。

Discussion