ブラウザ内で完全に動作する開発サーバーを構築した方法
Development • React Native • December 18, 2025 • 10分で読める
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を実行したときに何が起こるかを理解しましょう:
従来の開発サーバーフロー:
Code Editor → File System → Metro Bundler → Dev Server → Browser
開発サーバーはいくつかのことを行います:
- ファイルシステムの変更を監視
- 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.jsの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,
});
}
}
重要な洞察はデュアルVFSアーキテクチャです:
- Source VFS:生のユーザーファイル(TypeScript、JSX)を保存
- Destination VFS:変換されたファイル(JavaScript)を保存
Source VFSでファイルが変更されると、ウォッチャーが変換をトリガーし、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: [
[aliasPlugin, { aliases: { '@': '/src' } }],
],
sourceMaps: 'inline',
});
return result.code;
}
}
重要な洞察:Metroとは異なり、バンドルは行いません — トランスパイルのみです。各ファイルは個別のESモジュールのままです。
Viteとの関連:バンドルしないことが機能する理由
このアーキテクチャが馴染み深く感じるなら、それはViteとDNAを共有しているからです。RapidNativeとViteの両方が同じ核心的な洞察を活用しています:現代のブラウザはESモジュールをネイティブサポートしているので、なぜ開発中にバンドルするのか?
従来のMetroやWebpackなどのバンドラーは、すべてのモジュールを単一(または少数)のバンドルファイルに連結します。これは、ブラウザがESモジュールをネイティブサポートしていなかった時代には意味がありました。しかし今日、ブラウザは以下が可能です:
<script type="module">を介してESモジュールを直接読み込み
- Import Mapsを使用してbare importsを解決
- 個別のモジュールを別々にキャッシュ
このアンバンドルアプローチの利点:
- 高速起動:提供前にすべてをバンドルする必要がない
- 高速HMR:変更されたモジュールのみを再読み込み
- より良いキャッシュ:変更されていないモジュールはキャッシュされたまま
- シンプルなアーキテクチャ:複雑な依存関係グラフのバンドルが不要
RapidNativeがViteと異なる点:
本質的に、RapidNativeはViteのようなものですが、CLIやNode.jsを必要とせず、完全にブラウザ内で動作します。
4. ESモジュール + Import Maps = バンドル不要
ここで興味深いことが起こります。Metroのように依存関係をバンドルする代わりに、Import Mapsを使用したネイティブESモジュールを使用します:
<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>
ユーザーコードがreactをインポートするとき:
import React from 'react';
ブラウザはimport mapを介してCDN URLに解決します。バンドルは不要です。
esm.rapidnative.com CDN
標準のesm.shはReact Nativeパッケージではうまく動作しません。これらは未トランスパイル(JSXがJSXのまま)で出荷されるためです。そこで、esm.shをフォークして以下を行うesm.rapidnative.comを作成しました:
- React Native Web互換性のための
.web.jsファイルの自動解決
- ブラウザで実行できないネイティブモジュールのスキップ
react-native → react-native-webの自動マッピング
- React Native固有のパッケージの癖の処理
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',