ClaudeExpo2025/12/18 14:15

How I built a dev server entirely in the browser

要点だけを先に読めるように短く再構成したセクションです。

元記事

Quick Digest

要約

要点だけを先に読めるように短く再構成したセクションです。

claudejamodel: claude-sonnet-4-20250514

ブラウザ内で完結するReact Native開発サーバーの構築

Key Points

  • Service WorkerとVFSでブラウザ内開発サーバーを実現
  • 100ms以下のプレビュー更新とReact状態保持
  • バンドルレスESモジュール配信でViteライクな高速HMR

Summary

RapidNativeの開発者が、AIアシスト型アプリ構築ツールで100ms以下のプレビュー更新を実現するため、ブラウザ内で完結する開発サーバーを構築した事例。Service Worker、仮想ファイルシステム、ESモジュール、React Refreshを組み合わせて、従来のCLIベース開発環境をブラウザネイティブAPIで再現。

Key Points

  • Service Workerによる開発サーバー: /_sw/render/{tabId}/{projectId}/*のリクエストをインターセプトし、仮想ファイルシステムからファイルを配信
  • デュアルVFSアーキテクチャ: Source VFS(生ファイル)とDestination VFS(変換済みファイル)の2層構造でファイル管理
  • ESMifyトランスフォーメーション: プラグインベースでTypeScript/JSXをブラウザ実行可能なJavaScriptに変換
  • バンドルレス配信: ViteライクなESモジュール + Import Mapsによる個別モジュール配信でHMR高速化
  • React Refresh統合: コンポーネント状態を保持したままのホットリロード機能
  • React Native Web対応: 専用CDN(esm.rapidnative.com)でReact Nativeパッケージのブラウザ互換性を確保

Full Translation

翻訳

原文の流れを保ったまま読める翻訳セクションです。

claudejamodel: claude-sonnet-4-20250514

ブラウザ内で完全に動作する開発サーバーを構築した方法

ブラウザ内で完全に動作する開発サーバーを構築した方法

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 startnpm 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からファイルを提供します:

// src/modules/worker/index.tsから簡略化
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  // /_sw/*ルートのみをインターセプト
  if (!url.pathname.startsWith('/_sw')) {
    return;
  }
  event.respondWith(handleSWRequest(event.request));
});

async function handleSWRequest(request: Request): Promise<Response> {
  const url = new URL(request.url);
  // パース: /_sw/render/{tabId}/{projectId}/{...filePath}
  const [, , , tabId, projectId, ...filePathParts] = url.pathname.split('/');
  const filePath = '/' + filePathParts.join('/');
  
  // Virtual File Systemからファイルを取得
  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通信も処理し、ファイル操作のための双方向通信を可能にします:

// メインスレッド ↔ service worker通信のための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クラスを構築しました:

// src/modules/worker/lib/virtual-file-system/src/VirtualFileSystem.tsから簡略化
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';
  }
  
  // ファイル変更の監視 - これが私たちの"chokidar"
  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>;
}

// ESMifyパイプライン
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());      // Babel JSX/TS変換
esmify.use(new ReactHMRPlugin());   // React Refresh登録
esmify.use(new NativeWindPlugin()); // Tailwind/NativeWindサポート

コア変換は@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: [
        // パスエイリアス解決(@/ → /src/)
        [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-nativereact-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は、すべてのエクスポートを登録呼び出しでラップします:

// src/modules/worker/lib/virtual-file-system/src/plugins/ReactHMRPlugin.ts
class ReactHMRPlugin implements ESMifyPlugin {
  name = 'react-hmr';
  
  async transform(ctx: TransformContext): Promise<string> {
    // 登録呼び出しを追加するためにBabelで変換
    const result = Babel.transform(ctx.content, {
      presets: ['typescript',