空雲 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 を行う際に使用するブリッジ

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";
7
8async 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}
18
19export 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.fetchModule
40 >;
41 const result = await viteDevServer.environments.ssr.fetchModule(
42 ...args
43 );
44 return new Response(JSON.stringify(result));
45 },
46 },
47 };
48 if (
49 "compatibilityDate" in config.workerOptions &&
50 !config.workerOptions.compatibilityDate
51 ) {
52 delete config.workerOptions.compatibilityDate;
53 }
54 const options = mergeWorkerOptions(
55 miniflareOption,
56 config.workerOptions as WorkerOptions
57 ) as MiniflareOptions;
58
59 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";
6
7export type RunnerEnv = {
8 __viteUnsafeEval: {
9 eval: (
10 code: string,
11 filename?: string
12 ) => (...args: unknown[]) => Promise<void>;
13 };
14 __viteFetchModule: {
15 fetch: (request: Request) => Promise<Response>;
16 };
17};
18
19class 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 id
44 );
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}
55
56export 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";
12
13export 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}
58
59export function toRequest(nodeReq: Connect.IncomingMessage): MiniflareRequest {
60 const origin =
61 nodeReq.headers.origin && "null" !== nodeReq.headers.origin
62 ? nodeReq.headers.origin
63 : `http://${nodeReq.headers.host}`;
64 const url = new URL(nodeReq.originalUrl!, origin);
65
66 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 );
77
78 const init: RequestInit = {
79 method: nodeReq.method,
80 headers,
81 };
82
83 if (nodeReq.method !== "GET" && nodeReq.method !== "HEAD") {
84 init.body = nodeReq;
85 (init as { duplex: "half" }).duplex = "half";
86 }
87
88 return new MiniflareRequest(url, init);
89}
90
91export async function toResponse(
92 res: MiniflareResponse,
93 nodeRes: ServerResponse
94) {
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-unresolved
3import * as build from "virtual:remix/server-build";
4import type { AppLoadContext } from "@remix-run/cloudflare";
5
6const fetch = async (req: Request, context: AppLoadContext) => {
7 const handler = createRequestHandler(build);
8 return handler(req, context);
9};
10
11export 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";
8
9export 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";
3
4export const meta: MetaFunction = () => {
5 return [
6 { title: "New Remix App" },
7 {
8 name: "description",
9 content: "Welcome to Remix on Cloudflare!",
10 },
11 ];
12};
13
14export 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}
22
23export function loader() {
24 return {
25 userAgent: navigator.userAgent,
26 };
27}

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

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

まとめ

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