RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

vinxi でメタフレームワークをつくってみる

はじめに

vinxi はフルスタックアプリケーションやメタフレームワークの構築が可能なパッケージです。 開発サーバーとバンドラーに Vite を、本番サーバーには Nitro を使用しています。 SolidStartTanstackStart で採用されています。

今回は vinxi を使ってメタフレームワークを作ってみます。 進めるにあたり、公式のサンプルや以下の記事を参考にさせていただきました。

Bullding a React Metaframework with Vinxi

Simple RSC With Vinxi

セットアップ

package.json を作成し、

npm init -y

以下の内容を追加します。

{
  "name": "try-vinxi",
  "version": "1.0.0",
  "description": "",
  "type": "module",
  "scripts": {
    "dev": "vinxi dev",
    "build": "vinxi build",
    "preview": "vinxi preview"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@vinxi/react": "^0.2.5",
    "@vitejs/plugin-react": "^4.3.4",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "vinxi": "^0.5.0"
  },
  "devDependencies": {
    "@types/react": "^18.3.12",
    "@types/react-dom": "^18.3.1",
    "typescript": "^5.7.2"
  }
}

tsconfig.json を作成します。

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "esModuleInterop": true,
    "strict": true
  }
}

依存関係をインストールします。

npm install

SPA

まずはシンプルな SPA モードを作成します。

index.ts を作成

ルートに index.ts を作成します。空の HTML を返すだけのハンドラーです。

import { eventHandler } from 'vinxi/http';

export default eventHandler(() => {
  return new Response(
    `<!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Vinxi</title>
      </head>
      <body>
        <div id="root"></div>
        <script src="./src/entry-client.tsx" type="module"></script>
      </body>
    </html>`,
    {
      status: 200,
      headers: {
        'Content-Type': 'text/html',
      },
    }
  );
});

entry-client.tsx を作成

src ディレクトリを作成し、その中に entry-client.tsx を作成します。

import { createRoot } from 'react-dom/client';
import Counter from './counter';

createRoot(document.getElementById('root')!).render(<Counter />);

counter.tsx を作成します。

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <h1>Counter</h1>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

app.config.ts を作成

ルートに app.config.ts を作成し、createApp 内に vinxi の設定を記述します。

@vitejs/plugin-react を追加します。vinxi は Vite を使用しているため、Vite のプラグインをそのまま使用できます。

npm install @vitejs/plugin-react

spa ルーターを追加します。handler には先ほど作成した index.ts を指定します。

import { createApp } from 'vinxi';
import pluginReact from '@vitejs/plugin-react';

export default createApp({
  routers: [
    {
      name: 'spa',
      type: 'spa',
      handler: './index.ts',
      target: 'browser',
      plugins: () => [pluginReact()],
    },
  ],
});

開発サーバーを起動します。

npm run dev

カウンターが表示されることを確認します 🎉

SSR

次に SSR モードを作成します。

app.config.ts を編集

まずは app.config.ts を編集します。spa ルーターを削除し、clientssr ルーターを追加します。

import { createApp } from 'vinxi';
import pluginReact from '@vitejs/plugin-react';

export default createApp({
  routers: [
    {
      name: 'client',
      type: 'client',
      handler: './src/entry-client.tsx',
      target: 'browser',
      plugins: () => [pluginReact()],
      base: '/_build',
    },
    {
      name: 'ssr',
      type: 'http',
      handler: './src/entry-server.tsx',
      target: 'server',
      plugins: () => [pluginReact()],
    },
  ],
});

各ファイルを作成していきます。

app.tsx を作成

src ディレクトリに app.tsx を作成します。

createAssets は各アセットを注入するコンポーネントを作成します。遅延コンポーネントになっているため、Suspense でラップします。

import { getManifest } from 'vinxi/manifest';
import { createAssets } from '@vinxi/react';
import { Suspense } from 'react';
import Counter from './counter';

const Assets = createAssets(
  getManifest('client').handler,
  getManifest('client')
);

export default function App() {
  return (
    <html>
      <head>
        <Suspense>
          <Assets />
        </Suspense>
      </head>
      <body>
        <div id="root">
          <Counter />
        </div>
      </body>
    </html>
  );
}

entry-client.tsx を編集

createRoothydrateRoot に変更します。

クライアントのランタイムを読み込むために vinxi/client をインポートします。

import { hydrateRoot } from 'react-dom/client';
import App from './app';
import 'vinxi/client';

hydrateRoot(document, <App />);

entry-server.tsx を作成

src ディレクトリに entry-server.tsx を作成します。

import { getManifest } from 'vinxi/manifest';
import { eventHandler } from 'vinxi/http';
import { renderToPipeableStream } from 'react-dom/server';
import App from './app';

export default eventHandler({
  handler: async (event) => {
    const clientManifest = getManifest('client');
    const stream = await new Promise(async (resolve) => {
      const stream = renderToPipeableStream(<App />, {
        onShellReady() {
          resolve(stream);
        },
        bootstrapModules: [
          clientManifest.inputs[clientManifest.handler].output.path,
        ],
        bootstrapScriptContent: `window.manifest = ${JSON.stringify(
          await clientManifest.json()
        )}`,
      });
    });

    event.node.res.setHeader('Content-Type', 'text/html');
    return stream;
  },
});

開発サーバーを起動します。

npm run dev

SSR された HTML が返ってきました 🎉

File system routing

vinxi はファイルシステムルーティングを作成するための機能を提供しています。

FileSystemRouter を作成

vinxi の BaseFileSystemRouter を継承した FileSystemRouter を作成します。今回は app.config.ts の中に書いていきます。

toPath メソッドは引数にファイルのパスを受け取り、ルートのパスを返します。cleanPath はディレクトリ名と拡張子を取り除く vinxi のユーティリティ関数です。
toRoute メソッドは引数にファイルのパスを受け取り、ルートオブジェクトを返します。このルートオブジェクトは vinxi/routes モジュールによってアプリケーションに提供されます。

class FileSystemRouter extends BaseFileSystemRouter {
  toPath(src: string) {
    const routePath = cleanPath(src, this.config)
      .slice(1)
      .replace(/index$/, '');
    return routePath?.length > 0 ? `/${routePath}` : '/';
  }

  toRoute(filePath: string) {
    return {
      path: this.toPath(filePath),
      $component: {
        src: filePath,
        pick: ['default'],
      },
    };
  }
}

各ルーターの routes プロパティに FileSystemRouter を追加します。最終的に app.config.ts は以下のようになります。

import { createApp } from 'vinxi';
import pluginReact from '@vitejs/plugin-react';
import { BaseFileSystemRouter, cleanPath } from 'vinxi/fs-router';
import path from 'node:path';

class FileSystemRouter extends BaseFileSystemRouter {
  toPath(src: string) {
    const routePath = cleanPath(src, this.config)
      .slice(1)
      .replace(/index$/, '');
    return routePath?.length > 0 ? `/${routePath}` : '/';
  }

  toRoute(filePath: string) {
    return {
      path: this.toPath(filePath),
      $component: {
        src: filePath,
        pick: ['default'],
      },
    };
  }
}

export default createApp({
  routers: [
    {
      name: 'client',
      type: 'client',
      handler: './src/entry-client.tsx',
      target: 'browser',
      plugins: () => [pluginReact()],
      base: '/_build',
      routes: (router, app) => {
        return new FileSystemRouter(
          {
            dir: path.join(__dirname, 'src/routes'),
            extensions: ['tsx', 'ts'],
          },
          router,
          app
        );
      },
    },
    {
      name: 'ssr',
      type: 'http',
      handler: './src/entry-server.tsx',
      target: 'server',
      plugins: () => [pluginReact()],
      routes: (router, app) => {
        return new FileSystemRouter(
          {
            dir: path.join(__dirname, 'src/routes'),
            extensions: ['tsx', 'ts'],
          },
          router,
          app
        );
      },
    },
  ],
});

各ルートファイルを作成

先ほど指定した src/routes ディレクトリに、いくつかルートファイルを作成します。

src/routes/index.tsx

import Counter from '../counter';

export default function Index() {
  return (
    <div>
      <h1>Index</h1>
      <Counter />
    </div>
  );
}

src/routes/about.tsx

export default function About() {
  return (
    <div>
      <h1>About</h1>
      <p>This is the about page.</p>
    </div>
  );
}

src/routes/docs/guide.tsx

export default function Guide() {
  return (
    <div>
      <h1>Guide</h1>
      <p>This is the guide page.</p>
    </div>
  );
}

React Router をインストール

今回はルーターライブラリに React Router v6 を使用します。

npm install react-router-dom@6

app.tsx を編集

import { getManifest } from 'vinxi/manifest';
import { createAssets, lazyRoute } from '@vinxi/react';
import { Suspense } from 'react';
import fileRoutes from 'vinxi/routes';
import { Route, Routes } from 'react-router-dom';

const clientManifest = getManifest('client');
const ssrManifest = getManifest('ssr');

const routes = fileRoutes.map((route) => ({
  ...route,
  component: lazyRoute(route.$component, clientManifest, ssrManifest),
}));

const Assets = createAssets(clientManifest.handler, clientManifest);

export default function App() {
  return (
    <html>
      <head>
        <Suspense>
          <Assets />
        </Suspense>
      </head>
      <body>
        <div id="root">
          <Suspense>
            <Routes>
              {routes.map((route) => (
                <Route
                  key={route.path}
                  path={route.path}
                  element={<route.component />}
                />
              ))}
            </Routes>
          </Suspense>
        </div>
      </body>
    </html>
  );
}

entry-server.tsx を編集

App コンポーネントを StaticRouter でラップし、event.path を location に渡します。

<StaticRouter location={event.path}>
  <App />
</StaticRouter>

entry-client.tsx を編集

App コンポーネントを BrowserRouter でラップします。

hydrateRoot(
  document,
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

index.tsx にリンクを追加

export default function Index() {
  return (
    <div>
      <h1>Index</h1>
      <Counter />
      <Link to="/about">About</Link>
      <Link to="/docs/guide">Docs</Link>
    </div>
  );
}

開発サーバーを起動します。

npm run dev

リンクから各ページに遷移できることを確認します 🎉

おわりに

今回は vinxi を使って SSR とファイルシステムルーティングを備えたメタフレームワークを作成しました。 他にもミドルウェアや Server functions など、vinxi には様々な機能があります。 自分だけのメタフレームワークを作ってみるのも楽しそうですね。