Vite@6 + Cloudflare + Remix のvite devで本番環境を再現する
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 を行う際に使用するブリッジ
1import { build } from "esbuild";2import { ViteDevServer } from "vite";3import { Miniflare, mergeWorkerOptions, MiniflareOptions } from "miniflare";4import path from "path";5import { unstable_getMiniflareWorkerOptions } from "wrangler";6import fs from "fs";78async function getTransformedCode(modulePath: string) {9 const result = await build({10 entryPoints: [modulePath],11 bundle: true,12 format: "esm",13 minify: true,14 write: false,15 });16 return result.outputFiles[0].text;17}1819export const createMiniflare = async (viteDevServer: ViteDevServer) => {20 const modulePath = path.resolve(__dirname, "miniflare_module.ts");21 const code = await getTransformedCode(modulePath);22 const config = fs.existsSync("wrangler.toml")23 ? unstable_getMiniflareWorkerOptions("wrangler.toml")24 : { workerOptions: {} };25 const miniflareOption: MiniflareOptions = {26 compatibilityDate: "2024-08-21",27 modulesRoot: "/",28 modules: [29 {30 path: modulePath,31 type: "ESModule",32 contents: code,33 },34 ],35 unsafeEvalBinding: "__viteUnsafeEval",36 serviceBindings: {37 __viteFetchModule: async (request) => {38 const args = (await request.json()) as Parameters<39 typeof viteDevServer.environments.ssr.fetchModule40 >;41 const result = await viteDevServer.environments.ssr.fetchModule(42 ...args43 );44 return new Response(JSON.stringify(result));45 },46 },47 };48 if (49 "compatibilityDate" in config.workerOptions &&50 !config.workerOptions.compatibilityDate51 ) {52 delete config.workerOptions.compatibilityDate;53 }54 const options = mergeWorkerOptions(55 miniflareOption,56 config.workerOptions as WorkerOptions57 ) as MiniflareOptions;5859 const miniflare = new Miniflare({60 ...options,61 });62 return miniflare;63};
vitePlugin/miniflare_module.ts
Miniflare 内で動作するモジュールで、fetch を呼び出すことによって該当するスクリプトを実行します。WorkerdModuleRunnerは__viteFetchModuleで Node.js 側と通信し、Vite でビルドされたコードを受け取って、__viteUnsafeEval で実行可能状態に変換して実行します。
1import {2 FetchResult,3 ModuleRunner,4 ssrModuleExportsKey,5} from "vite/module-runner";67export type RunnerEnv = {8 __viteUnsafeEval: {9 eval: (10 code: string,11 filename?: string12 ) => (...args: unknown[]) => Promise<void>;13 };14 __viteFetchModule: {15 fetch: (request: Request) => Promise<Response>;16 };17};1819class WorkerdModuleRunner extends ModuleRunner {20 constructor(env: RunnerEnv) {21 super(22 {23 root: "/",24 sourcemapInterceptor: "prepareStackTrace",25 transport: {26 fetchModule: async (...args) => {27 const response = await env.__viteFetchModule.fetch(28 new Request("https://localhost", {29 method: "POST",30 body: JSON.stringify(args),31 })32 );33 return response.json<FetchResult>();34 },35 },36 hmr: false,37 },38 {39 runInlinedModule: async (context, transformed, id) => {40 const keys = Object.keys(context);41 const fn = env.__viteUnsafeEval.eval(42 `'use strict';async(${keys.join(",")})=>{${transformed}}`,43 id44 );45 await fn(...keys.map((key) => context[key as keyof typeof context]));46 Object.freeze(context[ssrModuleExportsKey]);47 },48 async runExternalModule(filepath) {49 return import(filepath);50 },51 }52 );53 }54}5556export default {57 async fetch(request: Request, env: RunnerEnv) {58 const runner = new WorkerdModuleRunner(env);59 const entry = request.headers.get("x-vite-entry")!;60 const mod = await runner.import(entry);61 const handler = mod.default as ExportedHandler;62 if (!handler.fetch) throw new Error(`Module does not have a fetch handler`);63 try {64 const result = handler.fetch(request, env, {65 waitUntil: () => {},66 passThroughOnException() {},67 });68 return result;69 } catch (e) {70 return new Response(String(e), { status: 500 });71 }72 },73};
vitePlugin/index.ts
Vite のプラグインとして Miniflare に対してリクエストを投げる処理をしています。
ここで面倒なポイントですが、依存モジュールに CommonJS が含まれている場合、optimizeDeps.include に、対象の依存ファイルを含んでいるモジュールを指定する必要があります。ここでは Remix を使用するために最低限必要なモジュールを設定しています。
1import { once } from "node:events";2import { Readable } from "node:stream";3import path from "path";4import { Connect, Plugin as VitePlugin } from "vite";5import type { ServerResponse } from "node:http";6import { createMiniflare } from "./miniflare";7import {8 Response as MiniflareResponse,9 Request as MiniflareRequest,10 RequestInit,11} from "miniflare";1213export function devServer(): VitePlugin {14 const plugin: VitePlugin = {15 name: "edge-dev-server",16 configureServer: async (viteDevServer) => {17 const runner = createMiniflare(viteDevServer);18 return () => {19 if (!viteDevServer.config.server.middlewareMode) {20 viteDevServer.middlewares.use(async (req, nodeRes, next) => {21 try {22 const request = toRequest(req);23 request.headers.set(24 "x-vite-entry",25 path.resolve(__dirname, "server.ts")26 );27 const response = await (await runner).dispatchFetch(request);28 await toResponse(response, nodeRes);29 } catch (error) {30 next(error);31 }32 });33 }34 };35 },36 apply: "serve",37 config: () => {38 return {39 ssr: {40 noExternal: true,41 target: "webworker",42 optimizeDeps: {43 include: [44 "react",45 "react/jsx-dev-runtime",46 "react-dom",47 "react-dom/server",48 "@remix-run/server-runtime",49 "@remix-run/cloudflare",50 ],51 },52 },53 };54 },55 };56 return plugin;57}5859export function toRequest(nodeReq: Connect.IncomingMessage): MiniflareRequest {60 const origin =61 nodeReq.headers.origin && "null" !== nodeReq.headers.origin62 ? nodeReq.headers.origin63 : `http://${nodeReq.headers.host}`;64 const url = new URL(nodeReq.originalUrl!, origin);6566 const headers = Object.entries(nodeReq.headers).reduce(67 (headers, [key, value]) => {68 if (Array.isArray(value)) {69 value.forEach((v) => headers.append(key, v));70 } else if (typeof value === "string") {71 headers.append(key, value);72 }73 return headers;74 },75 new Headers()76 );7778 const init: RequestInit = {79 method: nodeReq.method,80 headers,81 };8283 if (nodeReq.method !== "GET" && nodeReq.method !== "HEAD") {84 init.body = nodeReq;85 (init as { duplex: "half" }).duplex = "half";86 }8788 return new MiniflareRequest(url, init);89}9091export async function toResponse(92 res: MiniflareResponse,93 nodeRes: ServerResponse94) {95 nodeRes.statusCode = res.status;96 nodeRes.statusMessage = res.statusText;97 nodeRes.writeHead(res.status, Object.entries(res.headers.entries()));98 if (res.body) {99 const readable = Readable.from(100 res.body as unknown as AsyncIterable<Uint8Array>101 );102 readable.pipe(nodeRes);103 await once(readable, "end");104 } else {105 nodeRes.end();106 }107}
vitePlugin/server.ts
Remix を使用するために、Miniflare 上でモジュールとは別に最初に投入するスクリプトです。
1import { createRequestHandler } from "@remix-run/cloudflare";2// eslint-disable-next-line import/no-unresolved3import * as build from "virtual:remix/server-build";4import type { AppLoadContext } from "@remix-run/cloudflare";56const fetch = async (req: Request, context: AppLoadContext) => {7 const handler = createRequestHandler(build);8 return handler(req, context);9};1011export default { fetch };
vite.config.ts
Vite の設定ファイルにプラグインを追加します
1import {2 vitePlugin as remix,3 // cloudflareDevProxyVitePlugin as remixCloudflareDevProxy,4} from "@remix-run/dev";5import { defineConfig } from "vite";6import tsconfigPaths from "vite-tsconfig-paths";7import { devServer } from "./vitePlugin";89export default defineConfig({10 plugins: [11 // remixCloudflareDevProxy(),12 devServer(),13 remix({14 future: {15 v3_fetcherPersist: true,16 v3_relativeSplatPath: true,17 v3_throwAbortReason: true,18 },19 }),20 tsconfigPaths(),21 ],22});
app/routes/_index.tsx
実行管渠確認のため navigator.userAgent から Cloudflare 固有の文字列を取得して表示します。
1import type { MetaFunction } from "@remix-run/cloudflare";2import { useLoaderData } from "@remix-run/react";34export const meta: MetaFunction = () => {5 return [6 { title: "New Remix App" },7 {8 name: "description",9 content: "Welcome to Remix on Cloudflare!",10 },11 ];12};1314export default function Index() {15 const value = useLoaderData<Record<string, unknown>>();16 return (17 <div className="font-sans p-4">18 <pre>{JSON.stringify(value, null, 2)}</pre>19 </div>20 );21}2223export function loader() {24 return {25 userAgent: navigator.userAgent,26 };27}
実行後に表示されるもの
まとめ
今回の内容で Vite + Cloudflare + Remix のプログラムが開発モードでも本番環境に近い形で動作するようになりました。ただ、現状で色々問題があります。まず、node_modules に CommonJS が含まれている場合の対処です。そのままだと import に失敗するので、optimizeDeps.include からバンドルに必要なものを確認しながら追加していく必要があります。また、Prisma を使用する場合、wasm を import しなければならないのですが、Miniflare 上でスクリプト実行中に追加する術が見つかりませんでした。実用するにはまだまだ先が長そうです。