ブラウザだけでPythonが動く環境を作った
インストール不要、ブラウザだけでPythonが書けて実行できる環境を作りました。
なぜ作ったか
Pythonを学び始めるとき、環境構築でつまずくことが多いです。「とりあえずPythonを触ってみたい」という人が、ブラウザを開くだけで始められる場所があればいいなと思って作りました。
技術スタック
| カテゴリ | 技術 |
|---|---|
| Pythonランタイム | RustPython (WASM) |
| UIライブラリ | Preact |
| コードエディタ | CodeMirror 6 |
| スタイリング | Tailwind CSS v4 |
| ビルド / ランタイム | Bun |
| リンター | Biome |
| デプロイ | GitHub Pages |
RustPythonとは
RustPythonはRustで書かれたPythonインタプリタです。WebAssemblyにコンパイルできるため、ブラウザ上でPythonコードを実行できます。
似たプロジェクトにPyodide(CPythonベース)がありますが、RustPythonはRust製なのでWASMとの親和性が高いです。
WASMの初期化
import init, { pyExec } from "../wasm/rustpython_wasm.js";
import wasmUrl from "../wasm/rustpython_wasm_bg.wasm";
let initialized = false;
let initPromise: Promise<void> | null = null;
export async function initPythonRuntime(): Promise<void> {
if (initialized) return;
if (initPromise) return initPromise;
initPromise = (async () => {
await init({ module_or_path: wasmUrl });
initialized = true;
})();
return initPromise;
}
WASMバイナリは約50MBあるので、初期化は非同期で行います。initPromiseを保持することで、複数回呼ばれても初期化が一度だけ実行されるようにしています。
Pythonコードの実行
export function executePython(code: string, inputs: string[]): ExecutionResult {
let stdout = "";
let error: string | null = null;
const fullCode = buildPrelude(inputs) + code;
try {
pyExec(fullCode, {
stdout: (output: string) => {
stdout += output;
},
});
} catch (err) {
if (err instanceof WebAssembly.RuntimeError) {
const globalError = (globalThis as { __RUSTPYTHON_ERROR?: string })
.__RUSTPYTHON_ERROR;
error = globalError ?? String(err);
} else {
error = String(err);
}
}
return { stdout, error };
}
pyExecにコードとstdoutコールバックを渡すだけで実行できます。エラーが発生した場合、RustPythonはWebAssembly.RuntimeErrorをスローし、__RUSTPYTHON_ERRORグローバル変数にPythonのトレースバック情報を格納します。
input()の対応
ブラウザでPythonのinput()を動かすのは少し工夫が必要です。WASMは同期的に実行されるため、ユーザーの入力を途中で待つことができません。
そこで、事前に入力値をキューとして渡す仕組みにしました。
function buildPrelude(inputs: string[]): string {
const inputsLiteral = JSON.stringify(inputs);
return `\
import browser
import builtins
_input_queue = ${inputsLiteral}
_input_index = 0
def _custom_input(prompt=""):
global _input_index
if _input_index < len(_input_queue):
result = _input_queue[_input_index]
_input_index += 1
return result
return browser.prompt(prompt)
builtins.input = _custom_input
`;
}
ユーザーコードの前にこのプレリュードを挿入して、builtins.inputを差し替えています。キューが尽きた場合はbrowser.prompt()(ブラウザのプロンプトダイアログ)にフォールバックします。
UIにはInputパネルを用意していて、1行に1つの入力値を書きます。
プリレンダリング
WASMが50MBあるので初回読み込みに時間がかかります。ユーザーが白い画面を見続けなくて済むように、ビルド時にPreactコンポーネントをHTMLに埋め込んでいます。
import { h } from "preact";
import { render as renderToString } from "preact-render-to-string";
import { App } from "./src/App";
const appHtml = renderToString(h(App, null));
const preRendered = html.replace(
'<div id="root"></div>',
`<div id="root">${appHtml}</div>`,
);
ビルド時にrenderToStringでAppコンポーネントをHTML文字列に変換し、index.htmlに注入します。フロントエンドではrenderではなくhydrateを使うことで、プリレンダリングされたDOMをそのまま再利用します。
おまけ: Bunのビルドスクリプト
ViteやWebpackは使わず、BunのBun.build() APIだけでビルドしています。
const result = await Bun.build({
entrypoints,
outdir,
plugins: [plugin], // bun-plugin-tailwind
minify: true,
target: "browser",
sourcemap: "linked",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
});
HTML、TypeScript、Tailwind CSSのバンドル、minify、ソースマップ生成がこれだけで完結します。ビルドツールの設定ファイルが不要なのはかなり楽です。
まとめ
- RustPython (WASM) でブラウザ上にPython実行環境を構築
-
input()はキュー +browser.prompt()フォールバックで対応 - プリレンダリングで初回表示を高速化
- Bunだけでビルドが完結
Co-Authored-By: Claude Opus 4.6 noreply@anthropic.com
