📝

Remix + Cloudflare pages + i18nextで多言語対応する時のメモ

2022/06/16に公開

背景

Remix + Cloudflare pagesの構成で、アプリを多言語対応しようとしたときに発生した問題点や対策の忘備録

基本方針

「Remix i18next」とかでググると、remix-i18nextというライブラリが見つかるので、基本的にはこちらのReadmeの通りに進める(Installation〜Usageに記載の通りに実装していく)。
https://github.com/sergiodxa/remix-i18next

サーバーサイドの多言語化でfsまわりの問題が発生

上記Readmeに従い一通りの実装をし終えたあと、ローカルで動作確認してみると、Chromeの開発者ツールのログに以下のエラーが発生した。
Text content did not match. Server: "login" Client: "ログイン
日本語の設定で表示されるはずなので、サーバーサイドで正しく文言が変換されていないことになる。
以下の通り、デバッグを有効にして詳細のエラーを確認する。

i18n.ts
export default {
    debug : true, // デバッグを有効
    supportedLngs: ['en', 'ja'],
    ...省略
};
entry.server.tsx
...
import i18n from './i18n'; 
...
export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  let instance = createInstance();
  let lng = await i18next.getLocale(request);
  let ns = i18next.getRouteNamespaces(remixContext);
  await instance
    .use(initReactI18next) 
    .use(resourcesToBackend(lngs)) 
    .init({
      ...i18n, // 設定を更新
      lng, 
      ns, 
    });

すると、ターミナルには以下のエラーが出力された。

...
i18next::backendConnector: loading namespace common for language ja failed
TypeError: fs.readFile is not a function
...

エラーを辿ってみると、i18next-fs-backendのファイル読み込み箇所で落ちている。fs.readFileが存在しない?と思って調べていると、そもそもCloudflare workerのランタイムはNode.jsではなかった(V8 engine)...。なのでNodeのモジュールであるfsを使おうとした際に上記のようなエラーが発生する。
https://blog.cloudflare.com/introducing-cloudflare-workers/
ちなみにネット上にも同じような現象がちらほら...
https://community.cloudflare.com/t/cant-resolve-fs-in-cloudflare-workers/112762

サーバーサイド多言語化の方針転換

サーバーサイドの多言語のバックエンドにi18next-fs-backendを使用するのは諦め、代わりにメモリに展開するi18next-resources-to-backendを使用する。

% npm install i18next-resources-to-backend

entry.server.tsxは以下のような形になる。
(entry.client.tsxは変更なし)

entry.server.tsx
import { RemixServer } from "@remix-run/react";
import type { EntryContext } from "@remix-run/server-runtime";
import { createInstance } from "i18next";
import { renderToString } from "react-dom/server";
import { I18nextProvider, initReactI18next } from "react-i18next";
import i18next, { lngs } from "./i18next.server";
import i18n from './i18n'; 
import resourcesToBackend from "i18next-resources-to-backend"; // バックエンド
// 各言語ファイルをインポートしておく
import enCommon from '@public/locales/en/common.json'
import jaCommon from '@public/locales/ja/common.json'
// 言語/ネームスペース/文言のオブジェクトを用意
const lngs  = { 
  en : {
    common : enCommon
  },
  ja : {
    common : jaCommon
  }
}

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  let instance = createInstance();
  let lng = await i18next.getLocale(request);
  let ns = i18next.getRouteNamespaces(remixContext);

  await instance
    .use(initReactI18next) 
    .use(resourcesToBackend(lngs))  // バックエンドを適用
    .init({
      ...i18n, 
      lng, 
      ns, 
    });

  let markup = renderToString(
    <I18nextProvider i18n={instance}>
      <RemixServer context={remixContext} url={request.url} />
    </I18nextProvider>
  );

  responseHeaders.set("Content-Type", "text/html");

  return new Response("<!DOCTYPE html>" + markup, {
    status: responseStatusCode,
    headers: responseHeaders,
  });
}

各言語/ネームスペースの言語ファイルを一気に全て展開するというのは若干抵抗を感じる部分もあるが、現状worker上でbundle splitできる術が見つけられなかったので、一旦上記の形とすることに。
(参考)workerのbundle splittingについてはこちらとかでも議論されている。
https://github.com/cloudflare/wrangler2/issues/637

Discussion