Remix on Cloudflare WorkersをService WorkerからModule Workerに移行する

Cloudflare WorkersにデプロイするRemixアプリケーションをService Worker形式からModule Worker形式へ置き換える方法についてご紹介します。
2022.06.07

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

こんにちは、CX事業本部MAD事業部の森茂です。

RemixをCloudflare Workersで活用する際のブログ記事をいくつか紹介させていただきましたが、今回は今までService Worker形式でデプロイしていたRemixをModule Worker形式へ置き換える方法について紹介させていただきます。

今回の完成版ソースコードはこちら

Service WorkerとModule Worker

Service Worker

Service Worker形式ではこのような構文になっています。

ServiceWorker

addEventListener("fetch", (event) => {
  event.respondWith(new Response("Hello Worker!"));
}

Module Worker

今までのCloudflare Workersでは上記のようなService Worker形式の構文を利用してService Worker APIを利用していましたが、昨年末頃のアップデートによりESモジュールを利用できるModule Worker形式の構文が利用できるようになりました。

ModuleWorker

import doSomething from 'hoge'

export default {
  async fetch(request, environment, context) {
    const text = doSomething()
    return new Response("I’m a module!");
  }
}

Module Worker形式のfetchのパラメーターには、

  1. request リクエスト
  2. environment 環境変数を含むバインディングされたオブジェクト(Workers KVやDurable Objectsなど)
  3. context コンテキスト(waitUntilpassThroughOnExceptionといったライフサイクル制御)

が入ります。従来までは環境変数やWorkers KVなどのサービスはグローバルに展開されバインディングされたWorker内ではどこからもアクセスができましたが、Module Worker形式ではenvironmentから受け取って利用します。これにより名前空間の衝突や意図しないライブラリからの参照を防ぐことができるようになりました。

Module Workerのメリット

そのほか、Module Worker形式を利用するメリットとしては、

  1. Durable Objectsが利用できる
  2. Modules Workerはバインディングされたサービスをグローバルに展開しないので、名前空間の衝突や不要なアクセスなどなく安全に素早く展開ができる
  3. ESモジュールなのでnpmなどで共有、公開ができる。また他のModule Workerからもインポートして再利用することもできる

などが挙げられています。

対応しているモジュールのフォーマット

  • JSX
  • TypeScript
  • WebAssembly
  • HTML files

今後のService Workerについて

なお、新方式がリリースされたとはいえ従来のService Worker構文が非推奨になるということはなさそうです。この件についてはCloudflare社から下位互換性についての取り組みの記事も公開されています。

コンパクトなアプリケーションではむしろグローバルにバインディングされたサービスや環境変数にアクセスできた方が便利な場合もあります。現時点ではDurable Objectsの利用や、ミドルウェア的にキャッシュや処理を挟み込みたいという要件がなければ移行する必要はないかもしれません。

Durable Objectsについては下記記事も参照ください

RemixをService WorkerからModule Workerに移行する

Remixの標準テンプレートはService Workerとして動作するようになっており、Module Workerへの対応はまだされていません。(v1.5.1時点)

またこちらの記事でも紹介した通りWrangler v2への対応もまだ反映されていないため、記事を参考に調整したものをご用意いただくか、記事でも紹介しているボイラープレートをもとにModule Workerへの移行を行っていきます。

Remixのインストール

Wrangler v2環境へ対応させたボイラープレートからRemix環境を用意します。

$ npx create-remix@latest --template himorishige/remix-cloudflare-workers-boilerplate

Worker内でのデータのながれ

Service Workerではそれぞれがグローバルに展開された名前でアクセスが可能でしたが、Module Workerでは名前空間が区切られEnvironmentを経由して受け渡されます。

Module Workerではenvironmentというfetchの2つ目のパラメーターから環境変数やバインディングされたサービスが渡されてきます。

export default {
  async fetch(
    request: Request,
    environment: Env,
    context: ExecutionContext,
  ): Promise<Response> {

    return await handleRemixRequest(request, { environment });
  //...

Remixではそれを受け取りLoader Functionのcontextから取り出すことができるようになります。たとえば下記のようにRemix上ではcontextを通してenvironment.COUNTERという名前でバインディングされたサービスが利用できます。

export const loader: LoaderFunction = async ({ context: { environment }, request }) => {
  const counter = environment.COUNTER.get(env.COUNTER.idFromName('index'));
  //...

型定義の用意

まずバインディングするサービスや環境変数の型情報を用意します。

types/bindings.d.ts

declare module '__STATIC_CONTENT_MANIFEST' {
  const manifestJSON: string;
  export default manifestJSON;
}

interface Env {
  __STATIC_CONTENT: KVNamespace;

  SESSION_KV: KVNamespace;
  SESSION_SECRET: string;
}

__STATIC_CONTENT_MANIFESTはRemixのクライアントサイドで利用するjsなど静的ファイルの情報がWorkers KVへ格納されるためその型情報です。ここでは他にSESSION_SECRETという環境変数やSESSION_KVというWorkers KVを利用する場合を想定しています。

Module Workerではenvironmentというfetchの2つ目のパラメーターから環境変数やバインディングされたサービスが渡されてきますのでenvという値で受け取り、使いやすいようにRemixのLoaderFunction周りの型もあらかじめ拡張しておき、RemixのコンポーネントからはLoaderFunctionActionFunctionを新たに作る型から利用するようにします。

app/types/index.ts

import type {
  DataFunctionArgs as RemixDataFunctionArgs,
  Session,
} from '@remix-run/cloudflare';

export interface AppLoadContext {
  env: Env;
}

export interface DataFunctionArgs
  extends Omit<RemixDataFunctionArgs, 'context'> {
  context: AppLoadContext;
}

export interface ActionFunction {
  (args: DataFunctionArgs): null | Response | Promise<Response>;
}

export interface LoaderFunction {
  (args: DataFunctionArgs): null | Response | Promise<Response>;
}

Module Workerの用意

Service Workerとして利用していたserver.jsに代わり、新規にModule Workerのエントリーポイントとしてworker/index.tsを用意します。なおWrangler v2ではTypeScriptのファイルをそのまま指定可能です。

Remixがビルドごとに生成する静的ファイルをWorkers sitesに渡す部分などを用意する必要があるため少々長いソースとなっています。Remixがあらかじめ用意しているService Worker版ではこの部分は抽象化されていたため、用意する必要がありませんでした。ただここでキャッシュやETag、セッションの制御などもできるため重要な部分でもあります。

worker/index.ts

import {
  getAssetFromKV,
  MethodNotAllowedError,
  NotFoundError,
} from '@cloudflare/kv-asset-handler';
import { createRequestHandler } from '@remix-run/cloudflare';

// ビルドしたRemixをModule Workerへ静的ファイルとともにimportする
import manifestJSON from '__STATIC_CONTENT_MANIFEST';
import * as build from '../build';

import type { AppLoadContext } from '@remix-run/cloudflare';

const assetManifest = JSON.parse(manifestJSON);
const handleRemixRequest = createRequestHandler(build, process.env.NODE_ENV);

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext,
  ): Promise<Response> {
  // 静的ファイルの処理
  if (request.method === 'GET' || request.method === 'HEAD') {
    try {
      const url = new URL(request.url);
      const ttl = url.pathname.startsWith('/build/')
        ? 60 * 60 * 24 * 365 // 1 year
        : 60 * 5; // 5 minutes
      return await getAssetFromKV(
        {
          request,
          waitUntil(promise) {
            return ctx.waitUntil(promise);
          },
        },
        {
          ASSET_NAMESPACE: env.__STATIC_CONTENT,
          ASSET_MANIFEST: assetManifest,
          cacheControl: {
            browserTTL: ttl,
            edgeTTL: ttl,
          },
        },
      );
    } catch (error) {
      if (error instanceof MethodNotAllowedError) {
        return new Response('Method not allowed', { status: 405 });
      } else if (!(error instanceof NotFoundError)) {
        return new Response('An unexpected error occurred', { status: 500 });
      }
    }
  }

    // Remixとのやり取り部分
    try {
      //
      // ここで必要な場合はキャッシュの制御やセッション、ETagの用意などを行う
      //
      const loadContext: AppLoadContext = { env };
      return await handleRemixRequest(request, loadContext);
    } catch (error) {
      console.log(error);
      return new Response('An unexpected error occurred', { status: 500 });
    }
  },
};

import * as build from '../build';部分で読み込み先がjsとなり型の情報がないためTypeScriptのエラーが出ます。今回はエラーの解消のため型情報を含んだパッケージのexportを挟み込みます。あえて中間ファイルを用意せずに部分的にTypeScriptを無効化してもよいかもしれません。

build.d.ts

export * from '@remix-run/dev/server-build';

あわせてprocess.env.NODE_ENVについても型情報を追記しておきます。

remix.env.d.ts

/// <reference types="@remix-run/dev" />
/// <reference types="@remix-run/cloudflare" />
/// <reference types="@cloudflare/workers-types" />

declare var process: {
  env: { NODE_ENV: 'development' | 'production' };
};

remix.config.jsの修正

server.jsは利用しなくなったので削除もしくはコメントアウトしておきます。

/**
 * @type {import('@remix-run/dev').AppConfig}
 */
module.exports = {
  serverBuildTarget: 'cloudflare-workers',
  // server: "./server.js",
  devServerBroadcastDelay: 1000,
  ignoredRouteFiles: ['**/.*'],
  // appDirectory: "app",
  // assetsBuildDirectory: "public/build",
  // serverBuildPath: "build/index.js",
  // publicPath: "/build/",
};

wrangler.tomlの修正

エントリーポイントとしてTypeScriptをそのまま利用したいのでWrangler経由でビルドできるように変更します。mainで直接TypeScriptのファイルを指定するだけです。また型情報で用意したSESSION_SECRETを確認できるよう[env.dev]に変数を追記しておきます。

wrangler.toml

name = "remix-cloudflare-workers-boilerplate"
main = "worker/index.ts"
compatibility_date = "2022-06-06"

account_id = ""
workers_dev = true

[site]
bucket = "./public"

[env.dev]
vars = {SESSION_SECRET = "should-be-secure-in-prod"}

ビルドスクリプトの修正

すでにwrangler devで利用している場合は変更の必要はありませんが、ボイラープレートはminiflareを直接利用しているので書き換えていきます。

好みではありますが、今回並列実行のスクリプトをnpm-run-allではなくconcurrentlyに置き換えています。

$ yarn add -D concurrently

wrangler dev--localを付与することでローカル環境にて開発サーバーが起動します。また--env devdevという名前で環境を用意し変数やバインディングなど設定を環境ごとに分けることが可能です。

packages.json

"scripts": {
  "build": "remix build",
  "deploy": "npm run build && wrangler publish",
  "dev:miniflare": "cross-env NODE_ENV=development wrangler dev --env dev --local",
  "dev": "npm run build && concurrently \"npm run dev:miniflare\" \"remix watch\""
},

動作確認

編集箇所がだいぶ多くなりますがこれでService WorkerからModule Workerへの移行が完了です。動作確認として環境変数SESSION_SECRETの値をcontextから受け取り出力してみます。

app/routes/index.tsx

import { json } from '@remix-run/cloudflare';
import { useLoaderData } from '@remix-run/react';
import type { LoaderFunction } from '~/types';

export const loader: LoaderFunction = ({ context: { env } }) => {
  const secret = env.SESSION_SECRET;
  return json({ secret });
};

export default function Index() {
  const { secret } = useLoaderData();

  return (
    <div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.4' }}>
      <h1>Welcome to Remix</h1>
      <h2>{secret}</h2>
      {/* 以下省略 */}
    </div>
  );
}
$ yarn dev

http://localhost:8787をブラウザで開いてみます。

wrangler.tomlで登録したSESSION_SECRETの値が表示されました。

さいごに

だいぶ長くなりましたがこれにてService WorkerからModule Workerへの移行が完了です。これが正解という形はないのでさらにもう少しシンプルにわかりやすくできる部分もあるかもしれません。ぜひみなさんの使いやすい形を検討してみてください。

Module Workerに移行することで有料プラン限定ですがDurable Objectsが利用できるようになります。Workers KVにはない高速な強整合性を活かしたWebSocketとの連携でチャットなどリアルタイムのコレボレーションアプリも実現ができるなど構築できるアプリケーションの用途の幅はさらに広がります。

また先日Service Bindingsという複数のWorkerを紐付けることのできる機能(少し乱暴な言い回しですが)が利用できるようになり、API Gatewayとしての活用やマイクロサービスのような構成も手軽に構築できるようになりました。これによりWorker同士がインターネット経由ではなくプライベートなネットワークの中でほぼ遅延のない形で運用が可能になります。各Workerには1MBの制限があるので実用の幅がさらに広がります。

今後のRemixのアップデートによっては今回のような手順のいくつかは不要になるかもしれません。Cloudflare WorkersでService Worker形式が使えなくなることはなさそうということもあり、今後もRemix側には実装されない可能性もあります、ひとまずRemixをModule Workerで運用するひとつの方法として紹介させていただきました。

Service Bindingsについての参考記事

さいごにもうひとつ

ここまで作成したテンプレートは下記から利用が可能です。最初からModule Worker形式でRemixを構築される場合は下記もお試しください。

Module Worker版Remixボイラープレート

$ npx create-remix@latest --template himorishige/remix-cloudflare-workers-module-worker-boilerplate

Module Worker版Remixボイラープレート(Turborepo、Tailwind CSS)

$ npx create-remix@latest --template himorishige/remix-cloudflare-workers-do-boilerplate

参考情報