Remixを使って作ったアプリケーションをRemix v2へアップデートした時のメモです。

アプリケーションは Remix + Cloudflare Pages で動かしてるアプリ。

serverBuildTargetの変更

結構前にテンプレートから生成したアプリだったので、それぞれのビルドターゲットの設定方法が変わっている。 V2では、この初期値が結構変わるので、次のページを参考にしてビルドターゲットに合わせたものを設定する。

Cloudflare Pages向けのビルドをするので、remix.config.jsに次のように設定する。 この設定はV1でも動くので、まずはこれで動かす。(future.v2_devが有効である必要はあったのかも。忘れている)

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
-  serverBuildTarget: "cloudflare-pages",
   server: "./server.js",
   ignoredRouteFiles: [".*", "**/*.test.*"],
+  publicPath: "/build/", // default value, can be removed
+  serverBuildPath: "functions/[[path]].js",
+  serverConditions: ["worker"],
+  serverDependenciesToBundle: "all",
+  serverMainFields: ["browser", "module", "main"],
+  serverMinify: true,
+  serverModuleFormat: "esm", // default value in 2.x, can be removed once upgraded
+  serverPlatform: "neutral",
};

future.v2_XXXを使ってv1のままアップデートする

Remix では V1にフラグ付きでV2の機能が使えるようになっている。 そのため、V2に上げる前に、V1のままV2の挙動にきりかえることができる。

V1の最新まであげて、remix watchをして起動するDeprecation Warningを見ながら、v2_XXXフラグを有効化していく。

基本的には警告を見て、一つずつv2_headersなどのフラグを有効化して動くかを確認していく。

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
    ...
+    future: {
+        v2_headers: true
+    }
}

ルーターの変更以外は、そんなやることはなかったのでフラグを有効化して動いてるかを確認するぐらいだった。 (Metaはちょっと書き方が変わってたけどあんまり使ってなかった)

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
+    future: {
+        v2_headers: true
+        v2_meta: true,
+        v2_headers: true,
+        v2_errorBoundary: true,
+        v2_dev: true,
+        v2_normalizeFormMethod: true
+    }
}

ルーティングの対応

Remix v2ではFlat Routesというのがデフォルトになっている。

この変更を対応するのは難しすぎるので、V1と同じルーティングの動作をする @remix-run/v1-route-convention パッケージを使うことにした。

次のように、フラグと@remix-run/v1-route-conventionを使うとV1と同じルーティングの動作をする。

+ const { createRoutesFromFolders } = require("@remix-run/v1-route-convention");
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
    ...
    future: {
        v2_headers: true
        v2_meta: true,
        v2_headers: true,
        v2_errorBoundary: true,
        v2_dev: true,
        v2_normalizeFormMethod: true
+       v2_routeConvention: true,
    },
+    routes(defineRoutes) {
+      // uses the v1 convention, works in v1.15+ and v2
+      return createRoutesFromFolders(defineRoutes);
+    },
}

この時になぜかサーバが立ち上がらなくって、Cloudflare側のローカルサーバであるworkerdがエラーを吐いていた。

workerd/util/symbolizer.c++:99: warning: Not symbolizing stack traces because $LLVM_SYMBOLIZER is not set. To symbolize stack traces, set $LLVM_SYMBOLIZER to the location of the llvm-symbolizer binary. When running tests under bazel, use `--test_env=LLVM_SYMBOLIZER=<path>`.
*** Fatal uncaught kj::Exception: kj/async-io-unix.c++:945: failed: ::bind(sockfd, &addr.generic, addrlen): Address already in use; toString() = 0.0.0.0:8788
stack: /workspaces/app/node_modules/@cloudflare/workerd-linux-64/bin/workerd@31cdfba /workspaces/app/node_modules/@cloudflare/workerd-linux-64/bin/workerd@31cdd5f /workspaces/app/node_modules/@cloudflare/workerd-linux-64/bin/workerd@31cbfc5 /workspaces/app/node_modules/@cloudflare/workerd-linux-64/bin/workerd@168a085 /workspaces/app/node_modules/@cloudflare/workerd-linux-64/bin/workerd@168aa00 /workspaces/app/node_modules/@cloudflare/workerd-linux-64/bin/workerd@164afdd /workspaces/app/node_modules/@cloudflare/workerd-linux-64/bin/workerd@164fd47 /workspaces/app/node_modules/@cloudflare/workerd-linux-64/bin/workerd@164fac4 /workspaces/app/node_modules/@cloudflare/workerd-linux-64/bin/workerd@164faac /workspaces/app/node_modules/@cloudflare/workerd-linux-64/bin/workerd@32061de /workspaces/app/node_modules/@cloudflare/workerd-linux-64/bin/workerd@3205ddd /workspaces/app/node_modules/@cloudflare/workerd-linux-64/bin/workerd@32040c8 /workspaces/app/node_modules/@cloudflare/workerd-linux-64/bin/workerd@3203e8a /workspaces/app/node_modules/@cloudflare/workerd-linux-64/bin/workerd@1640293 /lib/x86_64-linux-gnu/libc.so.6@24082 /workspaces/app/node_modules/@cloudflare/workerd-linux-64/bin/workerd@164002d
✘ [ERROR] MiniflareCoreError [ERR_RUNTIME_FAILURE]: The Workers runtime failed to start. There is likely additional logging output above.

エラーに紛れてわかりにくいけど、failed: ::bind(sockfd, &addr.generic, addrlen): Address already in use; toString() = 0.0.0.0:8788となってるようになぜか8788ポートがすでに使われているというエラーになっていた。

実際にプロセスを見てみると、すでに止まってるはずの workerdnode が残ってることがあるので、これを止めると解決した。

ここまでで、feature flagを使ってV2の機能がV1で動くようになった。

node:assert がないエラー

ルーティングを切り替えると、node:assertモジュールがないというエラーが起きることがあった。 これは、app/以下にapp/routes/app.test.tsのようなテストファイルがあると起きていた。

ignoredRouteFiles: [".*", "**/*.test.*"],

で無視されるはず(少なくてもルーティングを切り替える前は問題なかった)なのに、無視されなくなっていた。 よくわからなかったので、app/routes以下にテストファイルを置くのをやめて解決した

Remix V2にバージョンを上げる。

まだ、remixのパッケージはv1.x.xのままなので、v2.x.xに上げる。

    "@remix-run/cloudflare": "^2.0.1",
    "@remix-run/cloudflare-pages": "^2.0.1",
    "@remix-run/dev": "^2.0.1",
    "@remix-run/eslint-config": "~2.0.1",
    "@remix-run/react": "^2.0.1",
    "@remix-run/serve": "~2.0.1",
    "@remix-run/v1-route-convention": "^0.1.4",

あげみると context.env が参照できなくなって、Cloudflare KVが参照できなくなった。 unstorageを使っていたので、context.env.KV_NAMEがundefinedになって、bindingが参照できなくて次のようなエラーが起きていた。

 Invalid binding STORAGE: undefined``

Remixはserver.jsというファイルでデプロイ先ごとのサーバの設定があるので、これが壊れる雰囲気だった。 マイグレーションガイドを読むと、Custom app serverを使ってる場合(Cloudflare Pagesとか大体全部カスタムという扱いだと思う)は、テンプレートを見て直してねとあった。

If you are using your own app server (server.js), then check out our templates for examples of how to integrate with v2_dev or follow these steps:

https://remix.run/docs/en/main/start/v2#custom-app-server

Cloudflare Pagesを使ってるので、テンプレートからserver.tsの内容をそのままコピーした。

import { logDevReady } from "@remix-run/cloudflare";
import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages";
import * as build from "@remix-run/dev/server-build";

if (process.env.NODE_ENV === "development") {
  logDevReady(build);
}

export const onRequest = createPagesFunctionHandler({
  build,
  // ここ古いテンプレと返すものが違うので注意。この返り値がcontextになる
  getLoadContext: (context) => ({ env: context.env }), 
  mode: build.mode,
});

コマンドの変更

npm run devのようなコマンドも仕組みが変わっているので、これもテンプレートを参考にコピーして直した KV Storageも使ってるような次のような形になった。

-    "build": "cross-env NODE_ENV=production remix build",
-    "dev": "run-p dev:*",
-    "dev:remix": "cross-env NODE_ENV=development remix watch",
-    "dev:wrangler": "cross-env NODE_ENV=development wrangler pages dev ./public --kv KV_NAME --compatibility-date=2023-03-14",
-    "start": "cross-env NODE_ENV=production npm run dev:wrangler",
+    "build": "remix build",
+    "dev": "remix dev --manual -c \"npm run start\"",
+    "start": "wrangler pages dev --kv KV_NAME --compatibility-date=2023-06-21 ./public",

これで、npm run devでRemix v2で起動するようになった。

context.envのコンパイルエラー

アプリ自体は動作しているが、TypeScriptの型がコンパイルエラーを起こしていた。

RemixのAppLoadContextの型定義が変わってunknownになったので、context.env.KV_NAMEがコンパイルエラーとなった。

AppLoadContextの定義を見てみると、拡張したい場合declaration mergingを使ってねとあった。

/**
 * An object of unknown type for route loaders and actions provided by the
 * server's `getLoadContext()` function.  This is defined as an empty interface
 * specifically so apps can leverage declaration merging to augment this type
 * globally: https://www.typescriptlang.org/docs/handbook/declaration-merging.html
 */
export interface AppLoadContext {
    [key: string]: unknown;
}

けどremix.env.d.tsで次のように拡張すると、loader関数の返り値がPromise<any>になったりおかしなことになった。

/// <reference types="@remix-run/dev" />
/// <reference types="@remix-run/cloudflare-pages" />
/// <reference types="@cloudflare/workers-types" />
declare module "@remix-run/server-runtime" {
    export interface AppLoadContext {
        env: {
            KV_NAME: unknown;
        };
    }
}

これは、@remix-run/server-runtimeを読み込む前に、AppLoadContextを拡張しようとして壊れる、読み込み順の問題だと思う。 Workaroundとしては、@remix-run/server-runtimeを読み込んでから拡張すれば、読み込み順の問題は解決する。

/// <reference types="@remix-run/dev" />
/// <reference types="@remix-run/cloudflare-pages" />
/// <reference types="@cloudflare/workers-types" />
import "@remix-run/server-runtime";
declare module "@remix-run/server-runtime" {
    export interface AppLoadContext {
        env: {
            KV_NAME: string;
        };
    }
}

ただ、declare mergingの挙動に頼りたくなかったので、次のようなヘルパーを定義してenvを参照するような方法に変更した。

import type { AppLoadContext } from "@remix-run/cloudflare";
export type MyAppLoadContext = {
    env: {
        KV_NAME: unknown;
    };
};

/**
 * Get env value
 * avoid to declare merging
 * @param context
 * @param key
 */
export const getEnv = (context: AppLoadContext, key: keyof MyAppLoadContext["env"]) => {
    return (context as MyAppLoadContext).env[key];
};

あとは、型エラーを直していくだけだった。

📝 loaderの型定義は2.0.1ではまだ壊れてるので2.1.0とかで修正されるらしい。

おわりに

RemixのFuture FlagでV1のままV2の機能を個別にアップデートしていけるのは良いと思った。

ただ、npm run devとかコマンドの変更やテンプレートレベルの変更はこういうアップデートに混ぜると迷子になるのでやめた方がいいと思った。 テンプレートから目視で確認してコピーしてくるみたいな、何が正解かよくわからない状況になった。 今回のアプリは設定はほとんどいじってないけど、カスタマイズしてるアプリケーションは、アップデートがかなり難しくなりそう。

結構手作業感が強いアップデートだったので、codemodとかで半分ぐらいは自動化できると良いなと思った。 けど、Remixはcodemodは諦めたようでした。