Remix on Cloudflare WorkersをService WorkerからModule Workerに移行する
はじめに
こんにちは、CX事業本部MAD事業部の森茂です。
RemixをCloudflare Workersで活用する際のブログ記事をいくつか紹介させていただきましたが、今回は今までService Worker形式でデプロイしていたRemixをModule Worker形式へ置き換える方法について紹介させていただきます。
今回の完成版ソースコードはこちら
Service WorkerとModule Worker
Service Worker
Service Worker形式ではこのような構文になっています。
addEventListener("fetch", (event) => { event.respondWith(new Response("Hello Worker!")); }
Module Worker
今までのCloudflare Workersでは上記のようなService Worker形式の構文を利用してService Worker APIを利用していましたが、昨年末頃のアップデートによりESモジュールを利用できるModule Worker形式の構文が利用できるようになりました。
import doSomething from 'hoge' export default { async fetch(request, environment, context) { const text = doSomething() return new Response("I’m a module!"); } }
Module Worker形式のfetch
のパラメーターには、
request
リクエストenvironment
環境変数を含むバインディングされたオブジェクト(Workers KVやDurable Objectsなど)context
コンテキスト(waitUntil
やpassThroughOnException
といったライフサイクル制御)
が入ります。従来までは環境変数やWorkers KVなどのサービスはグローバルに展開されバインディングされたWorker内ではどこからもアクセスができましたが、Module Worker形式ではenvironment
から受け取って利用します。これにより名前空間の衝突や意図しないライブラリからの参照を防ぐことができるようになりました。
Module Workerのメリット
そのほか、Module Worker形式を利用するメリットとしては、
- Durable Objectsが利用できる
- Modules Workerはバインディングされたサービスをグローバルに展開しないので、名前空間の衝突や不要なアクセスなどなく安全に素早く展開ができる
- 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')); //...
型定義の用意
まずバインディングするサービスや環境変数の型情報を用意します。
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のコンポーネントからはLoaderFunction
、ActionFunction
を新たに作る型から利用するようにします。
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、セッションの制御などもできるため重要な部分でもあります。
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を無効化してもよいかもしれません。
export * from '@remix-run/dev/server-build';
あわせてprocess.env.NODE_ENV
についても型情報を追記しておきます。
/// <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]
に変数を追記しておきます。
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 dev
でdev
という名前で環境を用意し変数やバインディングなど設定を環境ごとに分けることが可能です。
"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
から受け取り出力してみます。
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についての参考記事
- About Service bindings · Cloudflare Workers docs
- Cloudflare WorkersのService BindingsこそRemixアプリケーションでは積極的に採用したい | Zenn
さいごにもうひとつ
ここまで作成したテンプレートは下記から利用が可能です。最初から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