OpenAIExpoDec 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.

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

How I built a dev server entirely in the browser

Key Points

  • Service Worker replaces localhost dev server
  • Dual VFS separates source and transformed modules
  • ESM + import maps + React Refresh enable sub-100ms HMR

Summary

Sanket Sahu rebuilt a full dev-server experience inside the browser to achieve sub-100ms React Native previews for an AI-driven app builder. The approach replaces a traditional localhost server with a Service Worker that serves transformed ES modules from an in-browser Virtual File System (VFS). A plugin-based transformer (ESMify) uses @babel/standalone to transpile TSX/JSX per-file (no bundling), Import Maps + a custom CDN (esm.rapidnative.com) resolve dependencies (including react-native -> react-native-web), and React Refresh preserves component state during HMR.

Key Points

  • Service Worker as the dev server: intercepts /_sw/* requests, serves files from the Destination VFS, and exposes RPC to the main thread via MessageChannel for file ops and control.
  • Dual VFS design: Source VFS stores raw user files (TS/JSX); Destination VFS stores transformed JS modules. Source changes trigger watcher-driven transforms into Destination VFS.
  • ESMify transformation pipeline: plugin-based, runs Babel in the browser (@babel/standalone) to transpile each file into a standalone ES module — no bundling required.
  • Native ES modules + Import Maps: browser resolves bare imports directly; import map points to esm.rapidnative.com or local package stubs to satisfy React Native packages in the browser.
  • React Refresh HMR: a Babel/transform plugin injects registration calls so updates preserve React component state across module reloads.
  • Practical benefits & trade-offs: instant previews (<100ms), no CLI or Node dependency, faster HMR and caching. You must handle CDN mappings, polyfills for native modules, Service Worker scope/security, and storage limits (IndexedDB/OPFS constraints).

Implementation checklist for engineers

  • Implement Service Worker fetch handler for a dedicated dev scope (/ _sw /) and RPC via MessageChannel.
  • Build a VFS with change watchers that emits add/update/delete events and persists files (IndexedDB/OPFS as appropriate).
  • Create a plugin-based transformer using @babel/standalone to emit per-file ES modules and write outputs to Destination VFS.
  • Provide an import map mapping bare imports to CDN or local stubs; run a CDN (or fork) to adapt packages (react-native → react-native-web, .web.js resolution).
  • Integrate React Refresh transforms to wrap exports and coordinate HMR updates in the client runtime.

When to use this pattern

  • Fast, iterative previews for editor-integrated tooling (AI code streaming, playgrounds, in-browser sandboxes).
  • Projects where eliminating CLI/Node and minimizing dev latency are higher priority than exact parity with native runtime behavior.

Full Translation

Translations

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

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