空雲 Blog

Remix + Hono + Cloudflare Workers で process.env を使う

publication: 2024/11/18
update:2024/11/19

process.env が使えない問題

Cloudflare 用のプログラムを作る場合、Node.js ランタイム上では当たり前のように使えていた process.env が使用できないという問題の洗礼を受けます。Cloudflare では env を持った Context は、クライアントからのコネクションが成立した際に作られ、その後ではければ環境変数を参照できません。このため、Context が使用可能となった後に、環境変数を必要としている場所へ配らねばなりません。Node.js 用のプログラムを Cloudflare 向けに移植する際に、大幅にコードを書き換える必要が出てきます。

一応、Cloudflare Workers 用のプログラムはcompatibility_flagsnodejs_compatを加えることで、process.env が読み書きできるようになります。ただし、元々の中身は空です。

Hono を使って process.env を使えるようにする

サンプルは以下のリポジトリにあります。

https://github.com/SoraKumo001/remix-hono-workers/

初期コード作成

元となるコードは以下のコマンドでテンプレートから生成します。

1npm create cloudflare@latest . -- --framework=remix --experimental

server.ts

初期コードは完全に無視して、Hono を組み込んだコードに置き換えます。remix vite:devwrangler devの entry コードを共通化しています。元々のテンプレートだと server.ts はremix vite:devでは使われませんが、今回は共通のコードで動作させます。

Hono のcontextStorageでコンテキストをバケツリレーせずgetContextで取得出来るようにしています。さらにObject.getOwnPropertyDescriptorで process.env でcontext.envを返すようにしています。

./build/serverがビルド時、virtual:remix/server-buildは開発モード時に利用され、このモジュール読み込み時に Remix 用に作った各コードの実行が開始されます。

1import { Hono } from "hono";
2import { contextStorage, getContext } from "hono/context-storage";
3import {
4 type AppLoadContext,
5 createRequestHandler,
6} from "@remix-run/cloudflare";
7
8const app = new Hono();
9app.use(contextStorage());
10app.use(async (_c, next) => {
11 if (!Object.getOwnPropertyDescriptor(process, "env")?.get) {
12 const processEnv = process.env;
13 Object.defineProperty(process, "env", {
14 get() {
15 try {
16 return { ...processEnv, ...getContext().env };
17 } catch {
18 return processEnv;
19 }
20 },
21 });
22 }
23 return next();
24});
25
26app.use(async (c) => {
27 const build =
28 process.env.NODE_ENV !== "development"
29 ? import("./build/server")
30 : // eslint-disable-next-line @typescript-eslint/ban-ts-comment
31 // @ts-expect-error
32 // eslint-disable-next-line import/no-unresolved
33 import("virtual:remix/server-build");
34 const handler = createRequestHandler(await build);
35 return handler(c.req.raw, {
36 cloudflare: {
37 env: c.env,
38 },
39 } as AppLoadContext);
40});
41
42export default app;

vite.config.ts

Vite の設定で Hono を利用可能にしています。remix vite:devの開発モードでの起動時はserver.tsを読み込むようにしています。またexternalConditions: ["workerd", "worker"]を追加していますが、これがないと開発モードで React の renderToReadableStream がインポートエラーを起こすので注意してください。

1import { defineConfig } from "vite";
2import { vitePlugin as remix } from "@remix-run/dev";
3import tsconfigPaths from "vite-tsconfig-paths";
4import adapter from "@hono/vite-dev-server/cloudflare";
5import serverAdapter from "hono-remix-adapter/vite";
6
7declare module "@remix-run/cloudflare" {
8 interface Future {
9 v3_singleFetch: true;
10 }
11}
12
13export default defineConfig({
14 plugins: [
15 remix({
16 future: {
17 v3_fetcherPersist: true,
18 v3_relativeSplatPath: true,
19 v3_throwAbortReason: true,
20 v3_singleFetch: true,
21 v3_lazyRouteDiscovery: true,
22 },
23 }),
24 serverAdapter({
25 adapter,
26 entry: "server.ts",
27 }),
28 tsconfigPaths(),
29 ],
30 ssr: {
31 resolve: {
32 conditions: ["workerd", "worker", "browser"],
33 externalConditions: ["workerd", "worker"],
34 },
35 },
36 resolve: {
37 mainFields: ["browser", "module", "main"],
38 },
39 build: {
40 minify: true,
41 },
42});

wrangler.toml

compatibility_flagsの追加と、テスト用の環境変数を作っています。

1#:schema node_modules/wrangler/config-schema.json
2name = "remix-hono-workers"
3compatibility_date = "2024-11-12"
4compatibility_flags = ["nodejs_compat"]
5main = "./server.ts"
6assets = { directory = "./build/client" }
7
8# Workers Logs
9# Docs: https://developers.cloudflare.com/workers/observability/logs/workers-logs/
10# Configuration: https://developers.cloudflare.com/workers/observability/logs/workers-logs/#enable-workers-logs
11[observability]
12enabled = true
13
14[vars]
15a = "123"

app/routes/_index.tsx

Remix 用のコードですが、process.envをモジュール直下で使っています。この状態でも、きちんと動作することが確認できます。

1import { useLoaderData } from "@remix-run/react";
2
3export default function Index() {
4 const value = useLoaderData<string>();
5 return <pre>{value}</pre>;
6}
7
8// At the point of module execution, process.env is available.
9const value = JSON.stringify(process.env, null, 2);
10
11export const loader = () => {
12 return value;
13};

出力結果

Prisma をインポートした変数から直接使えるようにする

サンプルは以下のリポジトリにあります。

https://github.com/SoraKumo001/remix-hono-workers/tree/prisma

app/routes/_index.tsx

Node.js 用のコードだと、こんな形で PrismaClient を使うことが多いですが、Cloudflare 向けではこういうコードは使えません。しかし不可能を可能にしました。

1import { useLoaderData } from "@remix-run/react";
2import { prisma } from "~/libs/prisma";
3
4export default function Index() {
5 const value = useLoaderData<string>();
6 return <div>{value}</div>;
7}
8
9export async function loader(): Promise<string> {
10 //You can directly use the PrismaClient instance received from the module
11 const users = await prisma.user.findMany();
12 return JSON.stringify(users);
13}

libs/prisma.ts

prismaという変数を直接使っているように見えますが、実際にはgetContextで取得した PrismaClient インスタンスを返しています。このようにすることで、PrismaClient インスタンスを見た目上、変数から直接使うことができます。

今回 process.env を使っていますが、本来は getContext 側から D1Database を持ってくるほうが良いでしょう。

1import { PrismaClient } from "@prisma/client";
2import { PrismaD1 } from "@prisma/adapter-d1";
3import { getContext } from "hono/context-storage";
4
5type Env = {
6 Variables: {
7 prisma: PrismaClient;
8 };
9};
10
11// Create a proxy that returns a PrismaClient instance on SessionContext with the variable name prisma
12export const prisma = new Proxy<PrismaClient>({} as never, {
13 get(_target: unknown, props: keyof PrismaClient) {
14 const context = getContext<Env>();
15 if (!context.get("prisma")) {
16 const adapter = new PrismaD1(process.env.DB as unknown as D1Database);
17 context.set("prisma", new PrismaClient({ adapter }));
18 }
19 return context.get("prisma")[props];
20 },
21});

出力結果

まとめ

Cloudflare 向けのプログラムを極力 Node.js に近い形で書くために、Hono を使って process.env を使えるようにしました。また、PrismaClient を直接変数から使えるようにすることで、Node.js のコードをそのまま使えるようにしました。これにより、Node.js のコードを Cloudflare 向けに移植する際に、大幅なコードの書き換えを減らすことができます。