OpenAIExpo2025/12/18 14:15

How I built a dev server entirely in the browser

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

元記事

Quick Digest

要約

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

openaijamodel: gpt-5-mini-2025-08-07

ブラウザだけで動く開発サーバを作った話

Key Points

  • Service Workerが開発サーバの中核
  • VFSでソースと変換済みを分離
  • ESM+Import Mapでバンドル不要

Summary

Sanket Sahu(RapidNative)の実装解説。Service Worker、仮想ファイルシステム(VFS)、ESM化パイプライン(ESMify)、Import Map、そして React Refresh を組み合わせて、ブラウザ内で完全に完結する開発サーバを構築している。結果として、編集からプレビュー反映までサブ100msで行え、CLIや外部バンドラを不要にして即時プレビューを実現している。

Key Points

  • Service Worker を「サーバ」として利用
    • /_sw/* のフェッチを傍受して Destination VFS からファイルを返す。
    • MessageChannel を使った RPC でメインスレッドと双方向通信し、ファイル操作を行う。
  • 仮想ファイルシステム(VFS)の二層構成
    • Source VFS:TypeScript/JSX 等の生ファイルを保持(IndexedDB などに格納)。
    • Destination VFS:トランスパイル後の ES モジュールを保持。Source の変更で自動的に変換して書き出す。
    • VFS にウォッチャを実装してファイル変更をトリガーに変換を起動。
  • ESMify(プラグイン式変換パイプライン)
    • @babel/standalone を用いて TSX/JSX をブラウザでトランスパイル。
    • プラグインで React Refresh 登録や NativeWind 等の処理を差し込む。
    • バンドルせずファイルを個別 ES モジュールのまま提供(Vite と同様の思想)。
  • ES Modules と Import Maps を利用
    • 依存は importmap で CDN(esm.rapidnative.com 等)へマッピングし、ブラウザが直接解決する。
    • これにより開発時のバンドリングを回避して起動と HMR を高速化。
  • esm.rapidnative.com:React Native 向けの CDN フォーク
    • react-native → react-native-web マッピング、.web.js 優先解決、ブラウザ非対応モジュールのスキップなど。
  • React Refresh による状態保持付き HMR
    • 各モジュールにリフレッシュ用の登録コードを挟み、React コンポーネントの状態を維持したまま差分反映。

実装上の実用的な注意点

  • MIME と Cache-Control を正しく返す(Service Worker で no-cache を設定するとデバッグが安定)。
  • IndexedDB/OPFS の制約を考慮し、VFS の永続化戦略を選定する。
  • Import Map と CDN に依存するため、ネットワークや CDN の可用性・バージョン管理を設計に組み込む。
  • 本手法は開発体験(速い HMR、即時プレビュー)に最適化されており、本番配布には従来のバンドル戦略が依然必要になる場合が多い。

Conclusion

ブラウザのネイティブ機能(Service Worker / ES Modules / Import Maps / MessageChannel)を組み合わせることで、Node やローカルサーバを使わずに実用的な開発サーバを構築できる。短い反復時間と状態保持付き HMR を重視するツールに非常に有効なアプローチ。

Full Translation

翻訳

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

openaijamodel: gpt-5-mini-2025-08-07

ブラウザだけで開発サーバーを丸ごと構築した方法

ブラウザだけで開発サーバーを丸ごと構築した方法

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からファイルを返します。

// Simplified from src/modules/worker/index.ts
self.addEventListener('fetch', (event) => {
    const url = new URL(event.request.url);
    // Only intercept /_sw/* routes
    if (!url.pathname.startsWith('/_sw')) {
        return;
    }
    event.respondWith(handleSWRequest(event.request));
});

async function handleSWRequest(request: Request): Promise<Response> {
    const url = new URL(request.url);
    // Parse: /_sw/render/{tabId}/{projectId}/{...filePath}
    const [, , , tabId, projectId, ...filePathParts] = url.pathname.split('/');
    const filePath = '/' + filePathParts.join('/');
    // Get file from 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通信を行い、ファイル操作の双方向通信を可能にします:

// RPC system for main thread ↔ service worker communication
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クラスを実装しました。

// Simplified from 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';
    }
    // Watch for file changes - this is our "chokidar" watch
    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(),
        });
        // Emit event to trigger rebuild
        this.emit({ type: 'add', namespace: this.namespace, path: normalizedPath, content });
    }
    async updateFile(path: string, content: string | Blob): Promise<void> { /* Similar to addFile, but emits 'update' event */ }
    async deleteFile(path: string): Promise<void> { /* Delete and emit 'delete' event */ }
    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に変換します。

// Plugin interface
interface ESMifyPlugin {
    name: string;
    test: (path: string) => boolean;
    transform: (ctx: TransformContext) => Promise<string | null>;
}

// The ESMify pipeline class
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;
    }
}

// Usage
const esmify = new ESMify();
esmify.use(new ReactPlugin()); // Babel JSX/TS transformation
esmify.use(new ReactHMRPlugin()); // React Refresh registration
esmify.use(new NativeWindPlugin()); // Tailwind/NativeWind support

コアの変換には @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は各エクスポートを登録呼び出しでラップします:

// src/modules/worker/lib/virtual-file-system/src/plugins/ReactHMRPlugin.ts
class ReactHMRPlugin implements ESMifyPlugin {
  name = 'react-hmr';
  async transform(ctx: TransformContext): Promise<string> {
    // Transform with Babel to add registration calls
    const result = Babel.transform(ctx.content, {
      presets: ['typescript',