えっ?Browser内でNode.jsアプリが動く?? WebContainerAPIをTypeScriptで動かしてみた
概要
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
まずは、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
以下のように表示されれば、成功ですので、 にアクセスしてみましょう。
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を使う前準備として、自動生成された不要なものを削除して、ミニマルな状態にしていきましょう。
import './style.css';
document.querySelector('#app')!.innerHTML = ``;
自動生成された不要なファイルは削除してしまいましょう。
rm src/counter.ts
rm src/typescript.svg
チュートリアルの成果物としては、左側にtext-area、右側にWebContainerからの情報を表示するため、WebContainerが準備ができるまでの簡易的なローディングページを作ります
Installing dependencies...
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を作成します。
* {
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の設定を変更して、ヘッダーを付与します。
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を書き換えてみます。
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側のファイルを読み込んで表示させています。
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という型に従って、実際のファイル名とファイルの中身を定義します。
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が動いていそうなことを確認していましょう
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()とそれを呼び出す部分を追加しています)
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側からのメッセージを書き換えられるようにしてみます。
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