空雲 Blog

Eye catchVite@6 + Cloudflare + Remix のvite devで本番環境を再現する

publication: 2024/09/17
update:2024/09/17

Vite@6 について

Vite@6 では Environment API が追加されます。Vite 自体は Node.js 上で動作するのですが、vite dev 起動時にこの機能によって、本番環境を再現するのが容易になります。Vercel や Cloudflare の Edge 環境は、使用可能な API が限られているため、開発環境での再現が難しいです。この記事では、Vite@6 のベータ板で Cloudflare の環境を再現する方法を紹介します。

Miniflare の使用

Miniflare は Cloudflare がローカル環境で本番環境に近い動作を再現するのに使えるエミュレータです。Vite 実行時に 開発モードでビルドされたコードを逐次 Miniflare に投入することによって、Cloudflare の環境を再現することができます。

プラグインの作成

では、Vite で Miniflare を使用するためのプラグインを作成します。

ソースコードはこちらです。
https://github.com/SoraKumo001/remix-vite-miniflare

vitePlugin/miniflare.ts

Miniflare の初期化を行います。起動時のパラメータは wrangler.toml の設定もマージできるようにしています。

重要項目を掻い摘んで紹介します。

  • unsafeEvalBinding
    Miniflare 実行環境内で eval を呼び出す時に使用する名前

  • serviceBindings
    Miniflare 実行環境内で import を行う際に使用するブリッジ

import { build } from "esbuild"; import { ViteDevServer } from "vite"; import { Miniflare, mergeWorkerOptions, MiniflareOptions } from "miniflare"; import path from "path"; import { unstable_getMiniflareWorkerOptions } from "wrangler"; import fs from "fs"; async function getTransformedCode(modulePath: string) { const result = await build({ entryPoints: [modulePath], bundle: true, format: "esm", minify: true, write: false, }); return result.outputFiles[0].text; } export const createMiniflare = async (viteDevServer: ViteDevServer) => { const modulePath = path.resolve(__dirname, "miniflare_module.ts"); const code = await getTransformedCode(modulePath); const config = fs.existsSync("wrangler.toml") ? unstable_getMiniflareWorkerOptions("wrangler.toml") : { workerOptions: {} }; const miniflareOption: MiniflareOptions = { compatibilityDate: "2024-08-21", modulesRoot: "/", modules: [ { path: modulePath, type: "ESModule", contents: code, }, ], unsafeEvalBinding: "__viteUnsafeEval", serviceBindings: { __viteFetchModule: async (request) => { const args = (await request.json()) as Parameters< typeof viteDevServer.environments.ssr.fetchModule >; const result = await viteDevServer.environments.ssr.fetchModule( ...args ); return new Response(JSON.stringify(result)); }, }, }; if ( "compatibilityDate" in config.workerOptions && !config.workerOptions.compatibilityDate ) { delete config.workerOptions.compatibilityDate; } const options = mergeWorkerOptions( miniflareOption, config.workerOptions as WorkerOptions ) as MiniflareOptions; const miniflare = new Miniflare({ ...options, }); return miniflare; };

vitePlugin/miniflare_module.ts

Miniflare 内で動作するモジュールで、fetch を呼び出すことによって該当するスクリプトを実行します。WorkerdModuleRunner__viteFetchModuleで Node.js 側と通信し、Vite でビルドされたコードを受け取って、__viteUnsafeEval で実行可能状態に変換して実行します。

import { FetchResult, ModuleRunner, ssrModuleExportsKey, } from "vite/module-runner"; export type RunnerEnv = { __viteUnsafeEval: { eval: ( code: string, filename?: string ) => (...args: unknown[]) => Promise<void>; }; __viteFetchModule: { fetch: (request: Request) => Promise<Response>; }; }; class WorkerdModuleRunner extends ModuleRunner { constructor(env: RunnerEnv) { super( { root: "/", sourcemapInterceptor: "prepareStackTrace", transport: { fetchModule: async (...args) => { const response = await env.__viteFetchModule.fetch( new Request("https://localhost", { method: "POST", body: JSON.stringify(args), }) ); return response.json<FetchResult>(); }, }, hmr: false, }, { runInlinedModule: async (context, transformed, id) => { const keys = Object.keys(context); const fn = env.__viteUnsafeEval.eval( `'use strict';async(${keys.join(",")})=>{${transformed}}`, id ); await fn(...keys.map((key) => context[key as keyof typeof context])); Object.freeze(context[ssrModuleExportsKey]); }, async runExternalModule(filepath) { return import(filepath); }, } ); } } export default { async fetch(request: Request, env: RunnerEnv) { const runner = new WorkerdModuleRunner(env); const entry = request.headers.get("x-vite-entry")!; const mod = await runner.import(entry); const handler = mod.default as ExportedHandler; if (!handler.fetch) throw new Error(`Module does not have a fetch handler`); try { const result = handler.fetch(request, env, { waitUntil: () => {}, passThroughOnException() {}, }); return result; } catch (e) { return new Response(String(e), { status: 500 }); } }, };

vitePlugin/index.ts

Vite のプラグインとして Miniflare に対してリクエストを投げる処理をしています。

ここで面倒なポイントですが、依存モジュールに CommonJS が含まれている場合、optimizeDeps.include に、対象の依存ファイルを含んでいるモジュールを指定する必要があります。ここでは Remix を使用するために最低限必要なモジュールを設定しています。

import { once } from "node:events"; import { Readable } from "node:stream"; import path from "path"; import { Connect, Plugin as VitePlugin } from "vite"; import type { ServerResponse } from "node:http"; import { createMiniflare } from "./miniflare"; import { Response as MiniflareResponse, Request as MiniflareRequest, RequestInit, } from "miniflare"; export function devServer(): VitePlugin { const plugin: VitePlugin = { name: "edge-dev-server", configureServer: async (viteDevServer) => { const runner = createMiniflare(viteDevServer); return () => { if (!viteDevServer.config.server.middlewareMode) { viteDevServer.middlewares.use(async (req, nodeRes, next) => { try { const request = toRequest(req); request.headers.set( "x-vite-entry", path.resolve(__dirname, "server.ts") ); const response = await (await runner).dispatchFetch(request); await toResponse(response, nodeRes); } catch (error) { next(error); } }); } }; }, apply: "serve", config: () => { return { ssr: { noExternal: true, target: "webworker", optimizeDeps: { include: [ "react", "react/jsx-dev-runtime", "react-dom", "react-dom/server", "@remix-run/server-runtime", "@remix-run/cloudflare", ], }, }, }; }, }; return plugin; } export function toRequest(nodeReq: Connect.IncomingMessage): MiniflareRequest { const origin = nodeReq.headers.origin && "null" !== nodeReq.headers.origin ? nodeReq.headers.origin : `http://${nodeReq.headers.host}`; const url = new URL(nodeReq.originalUrl!, origin); const headers = Object.entries(nodeReq.headers).reduce( (headers, [key, value]) => { if (Array.isArray(value)) { value.forEach((v) => headers.append(key, v)); } else if (typeof value === "string") { headers.append(key, value); } return headers; }, new Headers() ); const init: RequestInit = { method: nodeReq.method, headers, }; if (nodeReq.method !== "GET" && nodeReq.method !== "HEAD") { init.body = nodeReq; (init as { duplex: "half" }).duplex = "half"; } return new MiniflareRequest(url, init); } export async function toResponse( res: MiniflareResponse, nodeRes: ServerResponse ) { nodeRes.statusCode = res.status; nodeRes.statusMessage = res.statusText; nodeRes.writeHead(res.status, Object.entries(res.headers.entries())); if (res.body) { const readable = Readable.from( res.body as unknown as AsyncIterable<Uint8Array> ); readable.pipe(nodeRes); await once(readable, "end"); } else { nodeRes.end(); } }

vitePlugin/server.ts

Remix を使用するために、Miniflare 上でモジュールとは別に最初に投入するスクリプトです。

import { createRequestHandler } from "@remix-run/cloudflare"; // eslint-disable-next-line import/no-unresolved import * as build from "virtual:remix/server-build"; import type { AppLoadContext } from "@remix-run/cloudflare"; const fetch = async (req: Request, context: AppLoadContext) => { const handler = createRequestHandler(build); return handler(req, context); }; export default { fetch };

vite.config.ts

Vite の設定ファイルにプラグインを追加します

import { vitePlugin as remix, // cloudflareDevProxyVitePlugin as remixCloudflareDevProxy, } from "@remix-run/dev"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { devServer } from "./vitePlugin"; export default defineConfig({ plugins: [ // remixCloudflareDevProxy(), devServer(), remix({ future: { v3_fetcherPersist: true, v3_relativeSplatPath: true, v3_throwAbortReason: true, }, }), tsconfigPaths(), ], });

app/routes/_index.tsx

実行管渠確認のため navigator.userAgent から Cloudflare 固有の文字列を取得して表示します。

import type { MetaFunction } from "@remix-run/cloudflare"; import { useLoaderData } from "@remix-run/react"; export const meta: MetaFunction = () => { return [ { title: "New Remix App" }, { name: "description", content: "Welcome to Remix on Cloudflare!", }, ]; }; export default function Index() { const value = useLoaderData<Record<string, unknown>>(); return ( <div className="font-sans p-4"> <pre>{JSON.stringify(value, null, 2)}</pre> </div> ); } export function loader() { return { userAgent: navigator.userAgent, }; }

  • 実行後に表示されるもの

{"width":"442px","height":"113px"}

まとめ

今回の内容で Vite + Cloudflare + Remix のプログラムが開発モードでも本番環境に近い形で動作するようになりました。ただ、現状で色々問題があります。まず、node_modules に CommonJS が含まれている場合の対処です。そのままだと import に失敗するので、optimizeDeps.include からバンドルに必要なものを確認しながら追加していく必要があります。また、Prisma を使用する場合、wasm を import しなければならないのですが、Miniflare 上でスクリプト実行中に追加する術が見つかりませんでした。実用するにはまだまだ先が長そうです。