Closed4

将棋ったーのRemix v2とViteへの移行メモ

na2hirona2hiro

Remix v2.2リリースにおいて、Viteのunstableサポートが開始されました。これまでRemixがビルドや開発サーバ等の開発体験など一式を受け持っていたものがViteに移譲され、開発者はViteの快適な開発体験を享受できます。

将棋ったーはRemix v1で作られた、100種類以上の変則将棋が指せる対局サイトです。一時バズった量子将棋もあります。

ここでは将棋ったーのRemix v2への移行、続いてViteへの対応の関するメモを残します。

na2hirona2hiro

まずViteを使うにはv2に上げなくてはなりません。

Remixはfuture flagsを採用しており、段階的にv2への移行を行っているところでした。future flagsは、例えばv1の間はもちろん後方互換性が保たれているものの、configでフラグを立てることによりその互換性を必ずしも担保しない状態でv2でリリース予定の機能を既に利用することが可能になるというものです。例えばv2_dev: trueとすれば新しい開発サーバについてはv2にアップグレードしても動くようになります。future flagsはメジャーバージョンアップをつまらなくするとも中の人は称しています。 [1]

正直なところ、好きなv2機能だけ取り込んでその他は後回しでいいじゃんと思っていたところでv2台でViteという大型featureが来たためv2移行を再開せざるを得なくなったというのが実情です。

将棋ったーが既に有効にしていたv2フラグは次の通りです

  • v2_dev (unstable_dev): コード変更時にページが更新されていたものが、HMR (Hot Module Replacement: 状態などを保持したままのバックエンド・フロントエンド変更の反映)かつHDR (Hot Data Revalidation: さらにバックエンドのloader等の変更によるデータの反映までをHMRを保って行う)に対応したサーバが可能に。自前expressサーバなども対応可。
  • v2_errorBoundary: CatchBoundaryとErrorBoundaryが統合されました。このErrorBoundaryはrouteからexportでき、loaderやactionの実行中やコンポーネントのレンダー中などに予期せぬエラーが起きた場合にエラーを描画するのに使われるとともに、404などの意図したエラーの描画にも使われます。
  • v2_normalizeFormMethod: <Form method="POST" />のメソッドが大文字になります。これはHTTPやfetchなどの標準に合わせるための変更です。

続いて将棋ったーが有効にしていなかったv2フラグを有効にすべく対応していきます。全てのフラグを有効にできたら、あとはパッケージをv2にバージョンアップするだけで無事v2対応完了という次第です。

脚注
  1. これはもちろんメジャーバージョンアップ時にあらゆる変更に対応するという大騒ぎにならないという意味でしょう。 ↩︎

na2hirona2hiro

Upgrading to v2を一つずつやっていきます。

v2_routeConvention (File System Route Convention)

これはflat routeとも呼ばれ、nested route等により深掘りされていたrouteがroutesディレクトリ以下に並ぶようになります。関連するrouteがファイルシステム上で名前順になることもあり管理しやすくなるようです。

大して魅力的には感じなかったので、compatライブラリを導入して次に行きます。

remix.config.js
+const { createRoutesFromFolders } = require("@remix-run/v1-route-convention");
 module.exports = {
   ...
+  routes(defineRoutes) {
+    return createRoutesFromFolders(defineRoutes);
+  },
 }

v2_headers (Route headers)

実はデプロイした後でこれに大して何もしていないことに気づきました(v2_metaと混同していた)。HTTP レスポンスヘッダは、これまでnested routeにおけるleaf route自身で設定されたもののみを返していたのを、自身を含む最も近い親の返すものに変更となりました。たまたま全てのケースでleaf routeのみで設定してあったため何もしなくても問題ないと判明しました。

v2_meta (Route meta)

<title>, <link>, <meta>などは、これまでない種類のものについてはnested routeにおける親の値を使うことになっていましたが、leaf route自身の値を使うことになりました。またkey-valueではなく配列になりました。

twitter向けのogタグなどサイト一括で指定している値もあるため、こちらもcompatを導入しました。

+ import { metaV1 } from "@remix-run/v1-meta";

-export const meta: MetaFunction = ({ data }) => {
+export const meta: ServerRuntimeMetaFunction<typeof loader> = (args) => {
   ...
-    return {
+    return metaV1(args, {
       ...metaTitle(`${title}`),
       ...metaDescription(`${title}のルール。将棋ったーで遊べます!`),
-    };
+   });

これで全てのv2フラグが立ちました。

v2のインストール

npx upgrade-remix latest

v2フラグを全て立てた後は、このようにただv2に上げるだけと思っていたのですが、まだやるべきことがいくつかありました。先程のUpgrading to v2の下の方にあります。また、冒頭からリンクされている2分のビデオも参考にしました。

これで全てではありませんが次のような細かなことをしました。

  • 自前のexpressサーバでinstallGlobals()を呼ぶ。 nodeのfetchを使ったりを使うためのもののようです。
  • source-map-supportも呼ぶ。
  • LoaderArgs -> LoaderFunctionArgsなど型名の変更

これで無事v2対応が完了しました。壊れていたE2Eテストも直して挙動を確認しました。次はVite対応をしていきます。

na2hirona2hiro

さて、本丸のViteへの移行をやっていきます。Vite (Unstable)のMigratingの節 の通りなのですが、それ以外で問題になったところを取り上げていきます。

ビルドスクリプトの移行

pnpm scriptsを移行する段階で引っかかりました。というのも、esbuildを使った自前スクリプトでビルドをしていたためです。

自前ビルドスクリプトのES Modules移行

ViteはESM firstのため、Node.jsが.jsファイルをESMで実行するようにします。

package.json
   ...
+  "type": "module",
   ...
 }

ビルドスクリプトをESM化します

scripts/build.ts
-const tsPaths = require("esbuild-ts-paths");
+import tsPaths from "esbuild-ts-paths";
+import esbuild from "esbuild";

-require("esbuild")
+esbuild
   .build({
   ...
+    format: "esm",
   ...

変則ビルドの解消

これはこのリポジトリ特有の問題なので隠しておきます

ところで、自前ビルドスクリプトに頼っていたのには訳があり、Socket.ioのサーバ側オブジェクトをサーババンドルとRemixバンドルの両方から参照するために、dynamic requireを使用し無理やりcode splitさせていたというものでした。Vite devサーバで同様に動かそうとしたところ、サーババンドルとviteバンドルが参照するSocket.ioのサーバ側オブジェクトが別物になってしまうようになり行き詰まりました。

RemixのGitHub discussionsを探したところ、「RemixでWebSocket使う際どうやってrouteから呼べるのか」discussionにコメントがなかったため、「そうだよな、具体的にはこうやりたいんだけどできなくてすごい大変」というコメントと具体例を書いたら、速攻で詳しい人から「LoadContextを使えばサーバ側からリクエストごとにloader, actionにcontextを渡せるよ」とコメントが来て解決しました。

実際の変則ビルドの概要はdiscussionのほうに書いてあります。

https://github.com/remix-run/remix/discussions/7454#discussioncomment-7498534

サーバのビルドスクリプトはサーバファイルをビルドするだけになりました。

scripts/build.js
 esbuild
   .build({
-    entryPoints: ["server.ts", "./app/sockets/defineSockets.ts"],
+    entryPoints: ["server.ts"],
server.ts
 app.all(  
   "*",
   createRequestHandler({
     ...
+    getLoadContext() {
+      return { ...sockets };
+    },
    }
  );

サーバ起動時に Dynamic require of X is not supported

CJSで提供されているnpmパッケージがrequireを使っていると、それがserver.js内でそのまま展開されてESMとして実行する際に問題になるようです。

Error: Dynamic require of "path" is not supported
    at file:///(...)/shogitter/dist/server.js:12:9
    at node_modules/.pnpm/source-map-support@0.5.21/node_modules/source-map-support/source-map-support.js (file:///(...)/shogitter/dist/server.js:1835:17)

発生元のパッケージをesbuildのexternalに追加すると、server.jsで該当パッケージのimportが保たれ実行時にはnode_modules配下のファイルが参照されます。エラーを読んでexternalに追加して実行することを繰り返します。

 esbuild
   .build({
     ...
     external: [
+      "@remix-run/dev",
+      "@remix-run/node",
+      "@remix-run/express",
+      "express",
+      "compression",
+      "morgan",
+      "dotenv",
+      "socket.io",
+      "source-map-support",
+      "mongodb",
+      "twitter-api-v2",
+    ],

"TypeError: Cannot read properties of undefined (reading 'module')" in @sentry/remix

Sentryによるエラー報告を使用しており、 Sentry.wrapExpressCreateRequestHandler(createRequestHandler)({build: ...})という風にハンドラをラップしているのですが、ここでエラーが起きています。buildオプションが関数を取れるようになった部分にまだ対応していないと分かりました。

これはviteのサーバビルドをawaitするようにするか、

  wrapExpressCreateRequestHandler(createRequestHandler)({
    build: vite
-     ? () => unstable_loadViteServerBuild(vite)
+     ? await unstable_loadViteServerBuild(vite)
      : await import(BUILD_DIR + "/index.js"),
    ...
  })

むしろdevでSentryを使わなくて良いので場合分けして

const createHandler = vite
  ? createRequestHandler
  : wrapExpressCreateRequestHandler(createRequestHandler);
const handlerBuild = vite
  ? () => unstable_loadViteServerBuild(vite)
  : await import(BUILD_DIR + "/index.js");
app.all(
  "*",
  createHandler({
    build: handlerBuild,
    ...
  })
);

などとするので十分でしょう。これらのワークアラウンドはsentry-javascriptのissuesに残しました。

https://github.com/getsentry/sentry-javascript/issues/9500#issuecomment-1804911488

server-onlyファイルへのimportが壊れる場合がある

app/db/ratingCollection.ts
import { getUserById, getUserByName } from "~/db/userCollection.server";

といった何の変哲もない箇所でgetUserByNameがないといったエラーが発生し、クライアントサイド遷移が阻害される問題(たしか)が置きました。試行錯誤した結果次のようにすると動きました。

import * as userColl from "~/db/userCollection.server";
const { getUserById, getUserByName } = userColl;

これは*.server.tsのtree shaking関連のバグのようで、issueにもなっています。

https://github.com/remix-run/remix/issues/7924

client-onlyファイルへのimportによるサーバビルドエラー

client-onlyコードを利用したときにサーバビルド vite build --ssrが失敗します。

file: ./shogitter/app/components/common/LoginButton.tsx:2:9
1: import Cookies from "js-cookie";
2: import { event } from "~/lib/gtags.client";
            ^
3: import { createCallbackWithTimeout } from "~/common/timeUtils";
error during build:
RollupError: "event" is not exported by "app/lib/gtags.client.ts", imported by "app/components/common/LoginButton.tsx".

これもserver-onlyコードの場合と同様import * as Xとして解消しました。これについてはissueを立てました。

https://github.com/remix-run/remix/issues/7972

本番実行時のbuild読み込みエラー

pnpm build && pnpm start時に

Error: Directory import '(...)/shogitter/build' is not supported resolving ES modules imported from (...)/shogitter/dist/server.js
Did you mean to import (...)/shogitter/build/index.js?

ESMではディレクトリの読み込みをindex.jsに解決するようにはなっていないようで、index.jsを追加する必要がありました。

 const handlerBuild = vite
   ? () => unstable_loadViteServerBuild(vite)
-  : await import(BUILD_DIR);
+  : await import(BUILD_DIR + "/index.js");
 app.all(
   "*",
   createHandler({
     build: handlerBuild,

なお、最新のexpress templateではindex.jsファイルを読み込むようになっているようで、migrationガイドには記載がありませんでした。[1]

本番実行時のCJS読み込みエラー

file:///(...)/shogitter/build/index.js:25
import { Store, ReactNotifications } from "react-notifications-component";
                ^^^^^^^^^^^^^^^^^^
SyntaxError: Named export 'ReactNotifications' not found. The requested module 'react-notifications-component' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:

import pkg from 'react-notifications-component';
const { Store, ReactNotifications } = pkg;

    at ModuleJob._instantiate (node:internal/modules/esm/module_job:132:21)
    at ModuleJob.run (node:internal/modules/esm/module_job:214:5)
    at ModuleLoader.import (node:internal/modules/esm/loader:329:24)
    at file:///(...)/shogitter/dist/server.js:3942:70

これはRemixのdocsの通りdefault imports関連の問題で、vite-plugin-cjs-interopプラグインにより解決します。2つのプラグインを列挙し動作するようになりました。

vite.config.ts
+import { cjsInterop } from "vite-plugin-cjs-interop";

 export default defineConfig({
   ...
   plugins: [
+    cjsInterop({
+      dependencies: [
+        "react-notifications-component",
+        "react-dnd-mouse-backend",
+       ],
+    }),
   ],
 });

FOUC (Flash of Unstyled Content)

CSSの当たっていないHTMLが一瞬表示された後CSSが当たるという問題で、自前サーバで起こる問題のようです。これは修正がマージされておりリリース待ちです。

https://github.com/remix-run/remix/pull/7937#event-10906516415

Prodでのサーバ起動時に@remix-run/devが見つからない

Prodデプロイの際次のエラーが発生しました。

 Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@remix-run/dev' imported from /myapp/dist/server.js

Production用のDockerイメージにdevDependenciesが含まれないようになっていたためでした。

# Setup production node_modules
FROM base as production-deps

WORKDIR /myapp

COPY --from=deps /myapp/node_modules /myapp/node_modules
ADD package.json pnpm-lock.yaml ./
RUN pnpm prune --prod

NODE_ENV=developmentが指定されている場合のみserver.tsで@remix-run/devをimportするようにしました。

server.ts
-import {
-  unstable_createViteServer,
-  unstable_loadViteServerBuild,
-} from "@remix-run/dev";

-const vite =
+const dev =
   process.env.NODE_ENV === "production"
     ? undefined
-    : await unstable_createViteServer();
+    : await import("@remix-run/dev");
+const vite = dev ? await dev.unstable_createViteServer() : undefined;

...

-const handlerBuild = vite
-  ? () => unstable_loadViteServerBuild(vite)
+const handlerBuild = dev
+  ? () => dev.unstable_loadViteServerBuild(vite!)

これでVite対応が完了

リリース直後かつ変則的ビルドを行っていただけあって様々なエラーに出くわしましたが、なんとか動くようになりE2Eテストも通りました。

まだunstableですが、人柱体験のため構わずデプロイしました。

https://shogitter.com

脚注
  1. Remixではしばしばライブラリではなくテンプレートのほうでベストプラクティス的なロジックが提供されることがありますが(templatesやより込み入ったstacksなど)、一度そのテンプレートからプロジェクトを開始してしまうとその後の改善を取り込むには手作業でやるしかない問題があり、これはその弊害と言えるかもしれません。 ↩︎

このスクラップは5ヶ月前にクローズされました