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

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/

初期コード作成

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

npm 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 用に作った各コードの実行が開始されます。

import { Hono } from "hono";
import { contextStorage, getContext } from "hono/context-storage";
import {
  type AppLoadContext,
  createRequestHandler,
} from "@remix-run/cloudflare";

const app = new Hono();
app.use(contextStorage());
app.use(async (_c, next) => {
  if (!Object.getOwnPropertyDescriptor(process, "env")?.get) {
    const processEnv = process.env;
    Object.defineProperty(process, "env", {
      get() {
        try {
          return { ...processEnv, ...getContext().env };
        } catch {
          return processEnv;
        }
      },
    });
  }
  return next();
});

app.use(async (c) => {
  const build =
    process.env.NODE_ENV !== "development"
      ? import("./build/server")
      : // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-expect-error
        // eslint-disable-next-line import/no-unresolved
        import("virtual:remix/server-build");
  const handler = createRequestHandler(await build);
  return handler(c.req.raw, {
    cloudflare: {
      env: c.env,
    },
  } as AppLoadContext);
});

export default app;

vite.config.ts

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

import { defineConfig } from "vite";
import { vitePlugin as remix } from "@remix-run/dev";
import tsconfigPaths from "vite-tsconfig-paths";
import adapter from "@hono/vite-dev-server/cloudflare";
import serverAdapter from "hono-remix-adapter/vite";

declare module "@remix-run/cloudflare" {
  interface Future {
    v3_singleFetch: true;
  }
}

export default defineConfig({
  plugins: [
    remix({
      future: {
        v3_fetcherPersist: true,
        v3_relativeSplatPath: true,
        v3_throwAbortReason: true,
        v3_singleFetch: true,
        v3_lazyRouteDiscovery: true,
      },
    }),
    serverAdapter({
      adapter,
      entry: "server.ts",
    }),
    tsconfigPaths(),
  ],
  ssr: {
    resolve: {
      conditions: ["workerd", "worker", "browser"],
      externalConditions: ["workerd", "worker"],
    },
  },
  resolve: {
    mainFields: ["browser", "module", "main"],
  },
  build: {
    minify: true,
  },
});

wrangler.toml

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

#:schema node_modules/wrangler/config-schema.json
name = "remix-hono-workers"
compatibility_date = "2024-11-12"
compatibility_flags = ["nodejs_compat"]
main = "./server.ts"
assets = { directory = "./build/client" }

# Workers Logs
# Docs: https://developers.cloudflare.com/workers/observability/logs/workers-logs/
# Configuration: https://developers.cloudflare.com/workers/observability/logs/workers-logs/#enable-workers-logs
[observability]
enabled = true

[vars]
a = "123"

app/routes/_index.tsx

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

import { useLoaderData } from "@remix-run/react";

export default function Index() {
  const value = useLoaderData<string>();
  return <pre>{value}</pre>;
}

// At the point of module execution, process.env is available.
const value = JSON.stringify(process.env, null, 2);

export const loader = () => {
  return value;
};

出力結果

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

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

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

app/routes/_index.tsx

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

import { useLoaderData } from "@remix-run/react";
import { prisma } from "~/libs/prisma";

export default function Index() {
  const value = useLoaderData<string>();
  return <div>{value}</div>;
}

export async function loader(): Promise<string> {
  //You can directly use the PrismaClient instance received from the module
  const users = await prisma.user.findMany();
  return JSON.stringify(users);
}

libs/prisma.ts

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

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

import { PrismaClient } from "@prisma/client";
import { PrismaD1 } from "@prisma/adapter-d1";
import { getContext } from "hono/context-storage";

type Env = {
  Variables: {
    prisma: PrismaClient;
  };
};

// Create a proxy that returns a PrismaClient instance on SessionContext with the variable name prisma
export const prisma = new Proxy<PrismaClient>({} as never, {
  get(_target: unknown, props: keyof PrismaClient) {
    const context = getContext<Env>();
    if (!context.get("prisma")) {
      const adapter = new PrismaD1(process.env.DB as unknown as D1Database);
      context.set("prisma", new PrismaClient({ adapter }));
    }
    return context.get("prisma")[props];
  },
});

出力結果

まとめ

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

GitHubで編集を提案

Discussion