ClaudeExpoDec 18, 2025, 2:15 PM

How I built a dev server entirely in the browser

A condensed section focused on the key takeaways first.

Original Post

Quick Digest

Summary

A condensed section focused on the key takeaways first.

claudeenmodel: claude-sonnet-4-20250514

Building a Complete Dev Server in the Browser with Service Workers and Virtual File System

Key Points

  • Sub-100ms React Native previews with zero external dependencies
  • Complete dev server running entirely in browser using Service Workers
  • React Refresh HMR with state preservation and no bundling required

Summary

A comprehensive guide to building a browser-based development server that achieves sub-100ms React Native previews without CLI tools or external servers. The implementation uses Service Workers, Virtual File System, ES modules, and React Refresh to create an instant development experience for AI-powered app building.

Key Points

  • Service Worker Architecture: Intercepts fetch requests to serve files from a Virtual File System, eliminating the need for localhost servers
  • Dual VFS System: Source VFS stores raw TypeScript/JSX files while Destination VFS stores transformed JavaScript, with file watchers triggering automatic transformations
  • ESMify Transformation Pipeline: Plugin-based system using @babel/standalone to convert TypeScript/JSX to browser-executable JavaScript without bundling
  • ES Modules + Import Maps: Leverages native browser ES module support with custom CDN (esm.rapidnative.com) for React Native Web compatibility
  • React Refresh Integration: Implements Hot Module Replacement with state preservation through component registration and update mechanisms
  • No Bundling Approach: Similar to Vite's philosophy, serves individual modules directly to browsers for faster startup and HMR

Technical Implementation

  • Custom RPC system enables bidirectional communication between main thread and Service Worker
  • IndexedDB-backed Virtual File System mimics Node.js fs module APIs
  • Forked esm.sh CDN handles React Native package quirks and .web.js file resolution
  • Plugin architecture supports extensible transformations for different file types

Full Translation

Translations

A translation section that keeps the flow of the original article.

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',