Firebase Realtime Database でみんなでライブコーディングできるサービス作ってみた【リメイク】
はじめに
2年ほど前の勉強会で JavaScriptコードゴルフ を紹介したときに使用した LIVE CODING THEATRE というアプリケーションを Firebase Realtime Database を使ってリメイクしてみました。
https://gyazo.com/fe996565fb077d94d0f2566df8098c75
リソース
Realtime Databaseの構成
Realtime Database の構成は次のとおりです。
code:ts
{
theatres: {
$key: {
meta: {
name: string; // シアターの名前
message?: string; // 画面上に表示するメッセージ
timerSeconds?: number; // タイマーの設定秒数
timerStartedAt?: number; // タイマー開始時刻のタイムスタンプ
},
audiences: {
$uid: {
name: string; // ユーザーの名前
code: string; // ユーザーのコード
}
},
}
}
}
また、セキュリティルールは次のとおりです。
ユーザーの名前とコードにはいくつかのバリデーションルールを設定しています。
シアターの情報については誰でも Read/Write できます。
code:database.rules.json
{
"rules": {
"theatres": {
"$theatreKey": {
".read": true,
"meta": {
".write": true
},
"audiences": {
"$uid": {
".write": "$uid === auth.uid",
"name": {
".validate": "newData.isString() && newData.val().length <= 20 && newData.val().length > 0 && !newData.val().matches(/^\\x20\\u3000*$/)" },
"code": {
".validate": "newData.isString() && newData.val().length <= 5000"
}
}
}
}
}
}
}
セキュリティルールで使えるメソッド・バリアブルは次のリンクが参考になりました。
コードを実行する疑似サンドボックスの実装
https://gyazo.com/1c1d418c3ee43505459125f0b060b762
入力されたコードの実行結果は表示時にブラウザ上で実行した結果を使用しています。
グローバルの拡張の禁止や alert 系メソッドの禁止をやるのが大変そうだったので、iframeで疑似サンドボックスを実装してコードを実行するようにしてみました。
コード実行を行う機能諸々は CodeRunner.vue 内に含めています。
コアの実装は次の2箇所です。
まずは、コードを実際に実行する iframe を作成する部分。
iframe では事前準備や入力されたコードの無限ループ対策のコードを挿入しています。
また、実行結果を返却する独自の console.answer() メソッドやエラーの内容を親ウィンドウに連携しています。
code:ts
if (runnerElement.value) {
document.body.removeChild(runnerElement.value);
}
const newToken = ${props.uid}-${Math.random().toString(36).slice(2)};
const $iframe = document.createElement('iframe');
/* eslint-disable no-useless-escape, prettier/prettier */
const docs = `
<script src="/js/infinite-loop-detector.js"><\/script>
<script>
window.addEventListener('error', (err) => window.parent.postMessage({ type: 'error', value: err.error.toString(), token: '${newToken}' }));
<\/script>
<script>
console.answer = (val) => {
const value =
typeof val === 'undefined' ? 'undefined' :
val === null ? 'null' :
typeof val === 'string' ? \"\${val}"\ :
typeof val === 'object' ? JSON.stringify(val) :
typeof val === 'function' && val.VERSION === _.VERSION ? \Lodash version \${_.VERSION}\ :
val.toString();
window.parent.postMessage({ type: 'answer', value, token: '${newToken}' });
const output = typeof val === 'string' ? \"\${val}"\ : val;
console.log('%cA.', 'background: #b3204d; font-weight: bold; color: #fff; padding: 2px 4px;', output); };
alert = () => { throw new Error('window.alert() の呼び出しは禁止されています。代わりに console.log() を使用してください。') };
confirm = () => { throw new Error('window.confirm() の呼び出しは禁止されています。') };
prompt = () => { throw new Error('window.prompt() の呼び出しは禁止されています。') };
<\/script>
<script>
window.parent.postMessage({ type: 'ready', token: '${newToken}' });
<\/script>
<script>
eval(infiniteLoopDetector.wrap(\${props.code.replaceAll('', '\\\')}\));
<\/script>
`;
/* eslint-enable no-useless-escape, prettier/prettier */
$iframe.srcdoc = docs;
$iframe.width = '0';
$iframe.height = '0';
$iframe.style.display = 'none';
$iframe.setAttribute('target', '_top');
document.body.appendChild($iframe);
runnerElement.value = $iframe;
token.value = newToken;
コンポーネント(親ウィンドウ)側では message イベントを受け取ると Data へ内容を挿入しています。
どの iframe から送信されたイベントかはコンポーネントが入力のたびに更新される Token で判定しています。
code:ts
const onMessage = async (event: MessageEvent): Promise<void> => {
if (event.data.token === token.value) {
if (event.data.type === 'ready') {
results.value = [];
return;
}
if (event.data.type === 'error') {
return;
}
try {
event.data.value = await formatCode(event.data.value, {
semi: false,
});
} catch (err) {
// do nothing.
}
results.value.push(event.data);
}
};
サンドボックス内で実行したコードが無限ループしていないかを検知するために xieranmaya/infinite-loop-detector を使用しました。
eval() にわたす文字列のうち、ループに類するコードをリプレイスして指定秒数以上に実行された場合に無限ループと判断しているようです。
Nuxt への Firebase の導入
Nuxt へ Firebase を導入するときは @nuxtjs/firebase モジュールが便利です。
Live Coding Theatre もこのモジュールを使用しました。
シンタックスハイライトするテキストエリア
https://gyazo.com/ede71609b9843f934e6e93164738c6f3
エディターではシンタックスハイライトするテキストエリアを使用しています。
ここでは、入力内容をシンタックスハイライトした要素に全く同じフォント、行間、フォントサイズ、余白のテキストエリアを重ねて表現しています。
もしかしたら、contenteditable な div でシンプルに実装できるかもしれません。
クライアントサイドでコードフォーマット
今回、エディターと一覧でソースコードをフォーマットする機能をつけました。
その際、クライアントサイドで Prettier を実行する際に prettier/standalone を使用しました。
下記が Prettier で JavaScript をフォーマットするときの最低構成です。
code:helpers/prettier.ts
import type { Options } from 'prettier';
export const formatCode = async (
code: string,
options: Options = {}
): Promise<string> => {
const prettier = await import('prettier/standalone');
const parserBabel = await import('prettier/parser-babel');
try {
return prettier.format(code, {
singleQuote: true,
parser: 'babel',
...options,
});
} catch (err) {
return code;
}
};
ちなみに、バンドルサイズはまあまあ大きいので使用する際は注意が必要です(Stats で合計 800KB くらい)。
https://gyazo.com/45e4f4e978f0b99c8e9be48fb0b0d900
タイマーの機能をまとめたヘルパー
Live Coding Theatre には、管理画面から設定した秒数カウントダウンを行うタイマー機能があります。
タイマー機能のうち、残秒数を計算する部分はヘルパーとしてまとめています。
Composition API がなかった頃は表現が難しかったストアの値を扱うユーティリティを簡単に実装できました。
余談:Composition APIの useXXX は Hooks と読んでいいのだろうか...(Vueのドキュメント上で明言されてなかった記憶)
Realtime Database で切断判定
Realtime Database にはクライアントとの接続が切断されたときにデータを書き換える機能があります。
Live Coding Theatre では切断時に参加時刻を空にすることで接続中かを判定できるようにしています。
code:js
const dbRef = app.$fire.database
.ref('theatres')
.child(key.value)
.child('audiences')
.child(currentAudience.value.key);
dbRef.onDisconnect().update({
enteredAt: null,
});
PR: vuex-typesafe-helper で Vuex を型安全に
Vuex は拙作 @lollipop-onl/vuex-typesafe-helper を使用しています。
TypeScript 4.1 を使える環境であれば beta 版を使用できます。
v2@beta を使用したコード:
👍 Yuki Agatsuma YukiAgatsuma.icon がいいねしました on 2021/1/7