ブラウザだけで開発サーバーを丸ごと構築した方法
Development • React Native • December 18, 2025 • 10 minutes read
Sanket Sahu(ゲスト投稿)
Service Workers、VFS、ESモジュール、React Refreshを使って、ブラウザ内でAI支援のアプリ構築におけるサブ100msのReact Nativeプレビューを実現する方法を解説します。この記事はGeekyAntsの共同創業者でgluestackの作者、Sanket Sahuによるゲスト投稿です。彼は現在React Native向けのAI搭載アプリビルダー「RapidNative」を開発しています。
ここ1年、私はRapidNative — React Native向けのAI搭載アプリビルダー — を開発してきました。コアの課題の一つはプレビューを「瞬時」にすることでした。単に「速い」ではなく「瞬時」です。リアルタイムでコードをストリーミングするAIツールでは、ミリ秒単位の遅延が魔法を壊してしまいます。以前のRapidNativeはクラウド上でサンドボックスを動かし、iframeに接続していました。動作はしましたが、ラウンドトリップの遅延が目立ちました。もっと良いものが必要でした。そこで、私は合理的なエンジニアがすることをしました:開発サーバー全体をブラウザ内に作り直しました。
目標:瞬時の速度、CLI不要
- コード変更とプレビュー更新の間の遅延をゼロにする
- npm run dev やその他のCLIコマンドは不要
- バンドリングやコンパイルのための外部サーバーは不要
- 状態を保持する完全なHMRサポート
- Expo Routerの即時サポート
結果:エディタでタイプすると、プレビューはReactの状態を保ったまま100ms未満で更新されます。
白板図(従来の開発サーバーがブラウザネイティブAPIにどうマップされるか)
従来の開発サーバーの理解
まず、npx expo start や npm run dev を実行したときに何が起きるかを理解しましょう。従来の開発サーバーフローは次の通りです:
コードエディタ → ファイルシステム → Metro Bundler → 開発サーバー → ブラウザ
開発サーバーは次のことを行います:
- ファイルシステムの変更を監視する
- TypeScript/JSXをJavaScriptにトランスパイルする
- すべてのモジュールを単一ファイル(またはチャンク)にバンドルする
- HTTP経由でバンドルを配信する
- クライアントに変更を通知する(HMR)
ここで私が問いました:これらすべてをブラウザ内で実現できるか?
ブラウザには必要なものがすべて揃っている
実は、モダンブラウザには開発サーバーの各要素を再現できるAPIが備わっています。各要素を分解して説明します。
1. Service Worker を開発サーバーとして使う
Service Workerはこのアーキテクチャの要です。localhost:3000でサーバーを動かす代わりに、Service Workerでfetchリクエストをインターセプトします。
/_sw/render/{tabId}/{projectId}/* へのリクエストはすべてService Workerで処理され、Virtual File Systemからファイルを返します。
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (!url.pathname.startsWith('/_sw')) {
return;
}
event.respondWith(handleSWRequest(event.request));
});
async function handleSWRequest(request: Request): Promise<Response> {
const url = new URL(request.url);
const [, , , tabId, projectId, ...filePathParts] = url.pathname.split('/');
const filePath = '/' + filePathParts.join('/');
const file = await destVFS.getFileInfo(filePath);
if (!file) {
return new Response('Not found', { status: 404 });
}
return new Response(file.content, {
headers: {
'Content-Type': file.mimeType,
'Cache-Control': 'no-cache',
},
});
}
Service Workerはまた、MessageChannel経由でメインスレッドとRPC通信を行い、ファイル操作の双方向通信を可能にします:
self.addEventListener('message', async (event) => {
const { method, args, callId } = event.data;
const port = event.ports[0];
const handler = rpcHandlers[method];
const result = await handler(...args);
port.postMessage({ callId, result });
});
const rpcHandlers = {
async listFiles(tabId, projectId) { },
async getFile(tabId, projectId, path) { },
async updateFile(tabId, projectId, path, content) { },
async deleteFile(tabId, projectId, path) { },
};
2. Virtual File System(VFS)
ブラウザは直接的なファイルシステムアクセスを持っていません(OPFSはありますが制約があります)。そこでNodeのfsモジュールを模したVirtual File Systemクラスを実装しました。
interface VirtualFile {
path: string;
content: string | Blob;
mimeType: string;
size: number;
lastModified: number;
}
class VirtualFileSystem {
private namespace: string;
private watchers: Set<(event: VFSEvent) => void> = new Set();
constructor(config: { namespace?: string } = {}) {
this.namespace = config.namespace || 'default';
}
watch(callback: (event: VFSEvent) => void): () => void {
this.watchers.add(callback);
return () => this.watchers.delete(callback);
}
private emit(event: VFSEvent): void {
this.watchers.forEach((callback) => callback(event));
}
async addFile(path: string, content: string | Blob, mimeType?: string): Promise<void> {
const normalizedPath = this.normalizePath(path);
await addFileToIndexedDB(this.namespace, {
path: normalizedPath,
content,
mimeType: mimeType || this.detectMimeType(normalizedPath),
lastModified: Date.now(),
});
this.emit({ type: 'add', namespace: this.namespace, path: normalizedPath, content });
}
async updateFile(path: string, content: string | Blob): Promise<void> { }
async deleteFile(path: string): Promise<void> { }
async listFiles(): Promise<VirtualFile[]> {
return await listFilesFromIndexedDB(this.namespace);
}
}
重要なインサイトはデュアルVFSアーキテクチャです:
- Source VFS:ユーザの生ファイル(TypeScript、JSX)を保存する
- Destination VFS:変換後のファイル(JavaScript)を保存する
Source VFSでファイルが変化すると、watcherが変換をトリガーし、Destination VFSに書き込みます。Service WorkerはDestination VFSから配信します。
3. ESMify:変換パイプライン
ここが魔法の部分です。ESMifyはプラグインベースの変換システムで、TypeScript/JSXをブラウザ実行可能なJavaScriptに変換します。
interface ESMifyPlugin {
name: string;
test: (path: string) => boolean;
transform: (ctx: TransformContext) => Promise<string | null>;
}
class ESMify {
private plugins: ESMifyPlugin[] = [];
use(plugin: ESMifyPlugin): this {
this.plugins.push(plugin);
return this;
}
async transform(path: string, content: string): Promise<string> {
let result = content;
for (const plugin of this.plugins) {
if (plugin.test(path)) {
result = await plugin.transform({ path, content: result });
}
}
return result;
}
}
const esmify = new ESMify();
esmify.use(new ReactPlugin());
esmify.use(new ReactHMRPlugin());
esmify.use(new NativeWindPlugin());
コアの変換には @babel/standalone を使用しています:
import * as Babel from '@babel/standalone';
class ReactPlugin implements ESMifyPlugin {
name = 'react';
test(path: string): boolean {
return /\.(tsx?|jsx?)$/.test(path);
}
async transform(ctx: TransformContext): Promise<string> {
const result = Babel.transform(ctx.content, {
filename: ctx.path,
presets: [
['typescript', { isTSX: true, allExtensions: true }],
['react', { runtime: 'automatic' }],
],
plugins: [
// Path alias resolution(@/ → /src/)
[aliasPlugin, { aliases: { '@': '/src' } }],
],
sourceMaps: 'inline',
});
return result.code;
}
}
重要なポイント:Metroとは異なり、バンドルは行わずトランスパイルのみを行います。各ファイルは個別のESモジュールのままです。
Viteとの接点:なぜバンドルが不要なのか
このアーキテクチャが馴染み深く聞こえるなら、それはViteと同じ考え方を持つからです。RapidNativeもViteと同じコアのインサイトを活用しています:モダンブラウザはネイティブでESモジュールをサポートしているため、開発中にバンドルする必要はない、ということ。
ブラウザは次のことが可能です:
- ESモジュールを直接ロードできる(<script type="module">)
- ベアインポートをImport Mapsで解決できる
- 個別のモジュールを別々にキャッシュできる
アンバンドルアプローチの利点:
- 起動が速い:事前に全てをバンドルする必要がない
- HMRが速い:変更されたモジュールだけをリロードすれば良い
- キャッシュが効く:変更のないモジュールはキャッシュされたまま
- アーキテクチャがシンプル:複雑な依存関係グラフのバンドルが不要
RapidNativeがViteと異なる点:本質的に、RapidNativeはViteに似ていますが、CLIやNode.jsを一切使わずブラウザだけで動作する点が違います。
4. ES Modules + Import Maps = バンドル不要
ここが面白いところです。Metroのように依存関係をバンドルする代わりに、ネイティブのESモジュールとImport Mapsを使います:
<script type="importmap">
{
"imports": {
"react": "https://esm.rapidnative.com/react@19.1.1?dev&bundle=true",
"react-dom": "https://esm.rapidnative.com/react-dom@19.1.1?dev&bundle=true",
"react-dom/client": "https://esm.rapidnative.com/react-dom@19.1.1/client?dev",
"react-native": "https://esm.rapidnative.com/react-native-web@0.21.1?dev",
"expo-router": "/packages/rapidnative-expo-router/index.js"
}
}
</script>
ユーザーコードが次のようにimportすると:
import React from 'react';
ブラウザはimport mapを使ってCDNのURLに解決します。バンドルは不要です。
esm.rapidnative.com CDN
標準のesm.shはReact Nativeパッケージと相性が良くありません(未トランスパイルのままJSXが残っていることがあるため)。そこで私はesm.shをフォークしてesm.rapidnative.comを作りました。これにより:
-
React Native Web互換のために .web.js ファイルを自動解決する
-
ブラウザで動かせないネイティブモジュールはスキップする
-
react-native を react-native-web に自動マップする
-
React Native固有のパッケージのクセを処理する
// Special package configuration
const SPECIAL_PACKAGES: Record<string, SpecialPackageConfig> = {
'react-native': { packageNameOverride: 'react-native-web', fixedVersion: '0.21.1', includeDev: true },
'expo-router': { customUrl: () => '/packages/rapidnative-expo-router/index.js' },
'react-native-reanimated': { customUrl: () => '/packages/react-native-reanimated-polyfill.js' },
};
5. React Refresh:実際に動くHMR
これが最も難しかった部分です。React Refreshはコンポーネントの状態を保持しながらHot Module Replacementを可能にします。ReactHMRPluginは各エクスポートを登録呼び出しでラップします:
class ReactHMRPlugin implements ESMifyPlugin {
name = 'react-hmr';
async transform(ctx: TransformContext): Promise<string> {
const result = Babel.transform(ctx.content, {
presets: ['typescript',