将棋ったーのRemix v2とViteへの移行メモ
Remix v2.2リリースにおいて、Viteのunstableサポートが開始されました。これまでRemixがビルドや開発サーバ等の開発体験など一式を受け持っていたものがViteに移譲され、開発者はViteの快適な開発体験を享受できます。
将棋ったーはRemix v1で作られた、100種類以上の変則将棋が指せる対局サイトです。一時バズった量子将棋もあります。
ここでは将棋ったーのRemix v2への移行、続いてViteへの対応の関するメモを残します。
まず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対応完了という次第です。
-
これはもちろんメジャーバージョンアップ時にあらゆる変更に対応するという大騒ぎにならないという意味でしょう。 ↩︎
Upgrading to v2を一つずつやっていきます。
v2_routeConvention
(File System Route Convention)
これはflat routeとも呼ばれ、nested route等により深掘りされていたrouteがroutes
ディレクトリ以下に並ぶようになります。関連するrouteがファイルシステム上で名前順になることもあり管理しやすくなるようです。
大して魅力的には感じなかったので、compatライブラリを導入して次に行きます。
+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対応をしていきます。
さて、本丸のViteへの移行をやっていきます。Vite (Unstable)のMigratingの節 の通りなのですが、それ以外で問題になったところを取り上げていきます。
ビルドスクリプトの移行
pnpm scriptsを移行する段階で引っかかりました。というのも、esbuildを使った自前スクリプトでビルドをしていたためです。
自前ビルドスクリプトのES Modules移行
ViteはESM firstのため、Node.jsが.jsファイルをESMで実行するようにします。
...
+ "type": "module",
...
}
ビルドスクリプトをESM化します
-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のほうに書いてあります。
サーバのビルドスクリプトはサーバファイルをビルドするだけになりました。
esbuild
.build({
- entryPoints: ["server.ts", "./app/sockets/defineSockets.ts"],
+ entryPoints: ["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",
+ ],
@sentry/remix
"TypeError: Cannot read properties of undefined (reading 'module')" in 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に残しました。
server-onlyファイルへのimportが壊れる場合がある
import { getUserById, getUserByName } from "~/db/userCollection.server";
といった何の変哲もない箇所でgetUserByName
がないといったエラーが発生し、クライアントサイド遷移が阻害される問題(たしか)が置きました。試行錯誤した結果次のようにすると動きました。
import * as userColl from "~/db/userCollection.server";
const { getUserById, getUserByName } = userColl;
これは*.server.tsのtree shaking関連のバグのようで、issueにもなっています。
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を立てました。
本番実行時の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つのプラグインを列挙し動作するようになりました。
+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が当たるという問題で、自前サーバで起こる問題のようです。これは修正がマージされておりリリース待ちです。
@remix-run/dev
が見つからない
Prodでのサーバ起動時に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 /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するようにしました。
-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ですが、人柱体験のため構わずデプロイしました。
-
Remixではしばしばライブラリではなくテンプレートのほうでベストプラクティス的なロジックが提供されることがありますが(templatesやより込み入ったstacksなど)、一度そのテンプレートからプロジェクトを開始してしまうとその後の改善を取り込むには手作業でやるしかない問題があり、これはその弊害と言えるかもしれません。 ↩︎