Capro Web

Remixにおけるサイトマップ送信

投稿日

Remixにおいてサイトマップの送信を検討する場合、リソースルートを用いることが考えらえれる。1一方で、送信されるリソースがリクエストに応じて動的に変更される2のでなければ、静的アセットとして提供することがより単純3な方法となる。

この記事では、hi-ogawa氏のアイデアをもとにRemix + Vite + @nasa-gcn/remix-seoでサイトマップを作成し、静的に送信する方法を紹介する。またソースコードはこちら。

環境は以下の通りである。

パッケージバージョン
React18.3.1
Remix2.12.0
@nasa-gcn/remix-seo2.0.1

サイトマップ作成のための準備

サイトマップから特定のルートを除外したい場合、またはデータベース等を用いてルートを動的に追加したい場合を除いて、特に事前の設定を行う必要はない。4

ただし、ドキュメントに記述されているもの以外に、以下のコード修正を行った。

Remixでは、存在しないルートへのアクセス処理をSplat Routeを用いて行う。しかし、たとえばapp/routes/$.tsxを作成し何も設定しない場合、サイトマップとして以下の<url>が生成される。5

<url>
  <loc>https://www.example.com/*</loc>
  <priority>0.7</priority>
</url>

sitemap.xmlにおいて*をワイルドカードとして解釈するような記述は、googleサイトマップ プロトコルを見た限り存在しない。

そこでサイトマップでSplat Routeがルートとして生成されないように修正を行う必要がある。

import { SEOHandle } from '@nasa-gcn/remix-seo'

export const handle: SEOHandle = {
  getSitemapEntries: () => null,
}

サイトマップの作成

サイトマップを作成するスクリプトを用意する。たとえば、プロジェクト直下にbuild-sitemap.jsを作成し、以下のように記述する。

import { writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { getSitemapXml } from '@nasa-gcn/remix-seo/build/sitemap/utils.js'
// eslint-disable-next-line import/no-unresolved
import * as build from './build/server/index.js'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const filePathForProd = path.join(
  __dirname,
  '.',
  'build',
  'client',
  'sitemap.xml',
)
const filePathForDev = path.join(__dirname, '.', 'public', 'sitemap.xml')

const sitemap = await getSitemapXml(
  new Request('https://dummy.local'),
  build.routes,
  {
    siteUrl: 'https://www.example.com',
  },
)

await Promise.all(
  [filePathForProd, filePathForDev].map((path) => {
    return writeFile(path, sitemap, 'utf8')
  }),
).catch((e) => console.error(e))

@nasa-gcn/remix-seoからgenerateSitemapではなくgetSitemapXmlをインポートしている。後者でサイトマップを作成し、それを用いて前者でリスポンスを作成する。当該ライブラリではgenerateSitemapを用いることを想定している。したがって2.0.1以降のバージョンでは、このコードは動作しない可能性がある。

サイトマップの書き込み先として、publicbuild/clientを指定している。これは、vite環境ではpublic配下のファイルが読み込まれ、ビルド環境ではbuild/client配下のファイルが読み込まれるからである。サイトマップはビルドに応じて作成されればよいため、.gitignoreに含めておく。

/build
/public/sitemap.xml

次に、package.jsonでビルドファイル作成後に、そのビルドファイルを用いてサイトマップを作成できるようにpostbuildスクリプトを追加する。6

{
  "scripts": {
    "build": "remix vite:build",
    "postbuild": "node ./build-sitemap.js"
  }
}

サーバーレス環境での追加設定

これまでのコードで、Node.js環境ではスクリプトが動作するはずである(より正確にはサーバー上でのHTMLレンダーにNode.js ストリームが利用可能な場合7)。

ところで、RemixをCloudflare環境で用いる場合、Web Streams向けのrenderToReadableStreamを用いる必要がある。これは、V8上で直接コードを実行する他のサーバーレス環境についても同様であろう。

そうするとサイトマップの作成を行う際、renderToReadableStreamを含むビルドファイルをNode.js環境で読み込む必要がある。

しかし、Reactのバージョン18.3.0においてpostbuildスクリプトを実行し、Node.js環境でビルドファイルを読み込むと以下のようなエラーが出る。

file:///C:/~/remix-cloudflare-sitemap/build/server/index.js:4
import { renderToReadableStream } from 'react-dom/server'
         ^^^^^^^^^^^^^^^^^^^^^^
SyntaxError: Named export 'renderToReadableStream' not found. The requested module 'react-dom/server' 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-dom/server';
const { renderToReadableStream } = pkg;

    at ModuleJob._instantiate (node:internal/modules/esm/module_job:146:21)
    at async ModuleJob.run (node:internal/modules/esm/module_job:229:5)
    at async ModuleLoader.import (node:internal/modules/esm/loader:473:24)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:123:5)

Node.js v20.17.0

この問題とその回避策についてはremew氏の記事に詳しい。要約すると、react-dom/serverからのimportは、実行環境に応じてファイルの読み込み先を切り替えている。特にNode.js環境を用いる場合、その読み込み先に指定されているファイルからはrenderToReadableStreamがexportされていないため上記エラーが生じるようである。

そこで回避策としては、renderToReadableStreamがexportされているファイルを直接読み込むこと、またそのための型定義が行われていないため補ってあげることが必要である。

import { renderToReadableStream } from 'react-dom/server'
import { renderToReadableStream } from 'react-dom/server.browser'
declare module 'react-dom/server.browser' {
  export * from 'react-dom/server'
}

サイトマップの送信

静的アセットの送信は、各種サーバーフレームワークやサービスで提供されている(と思われる)のでRemixを用いる必要がない。以下、Expressを用いる場合とCloudflare Pagesを用いる場合を紹介する。

Expressを用いる場合

express.static()によって、静的ファイルに対する設定を行うことが可能である。

import express from 'express'

const app = express()

app.use(express.static('build/client/sitemap.xml', { maxAge: 300 }))

Cloudflare Pagesを用いる場合

プロジェクトで指定されているビルド出力(Remixでは通常build/client)に、該当するファイルが存在する場合、そのファイルがレスポンスで送信される。したがって通常の場合、特別な設定を行う必要はない。

リスポンスヘッダーを設定したい場合は、_headersファイル(拡張子は必要ない)を用いる。Viteを用いる場合、publicフォルダ内に存在するファイルはbuild/clientにビルドされる。8そしてそのファイルがCloudflareに読み込まれるので、publicフォルダに_headersを作成すればよい。

以下は記述の例である。

/favicon.ico
  Cache-Control: public, max-age=3600, s-maxage=3600
/assets/*
  Cache-Control: public, max-age=31536000, immutable
/sitemap.xml
  Content-Type: application/xml
  Cache-Control: public, max-age=300

また、Cloudflare Pagesにおいて静的アセットのリクエストは無制限9なので、Functionsが無駄に実行されないように_routes.jsonで設定を行うと良い。_headers同様、publicフォルダで設定を行う。

{
  "version": 1,
  "include": ["/*"],
  "exclude": ["/favicon.ico", "/sitemap.xml", "/assets/*"]
}

静的アセットとリソースルートの比較

静的アセットとして送信することとリソースルートを用いて動的に作成・送信することは、どちらが妥当だろうか。

静的アセットの方法を用いることのメリットは以下の通りである。

一方で以下のデメリットも考慮する必要がある。

以上のメリット・デメリットのほかに、ビルド時にルートが確定可能か否かが判断基準となるだろう。

Footnotes

  1. リソースルートを用いた方法については、The Epic Stackを見よ。具体的には、以下の通りである。サーバーを起動するファイル(server/index.ts)においてbuildファイルをRemixアダプターのgetLoadContext()に渡す。次に リソースルート(app/routes/_seo+/sitemap[.]xml.ts)loaderで引数contextから当該buildファイルを受け取り、generateSitemap()に渡すことでサイトマップを含むリスポンスを作成・送信する。

  2. たとえば、ユーザーのリクエストに応じて動的に変更される場合、あるいはリアルタイムでリソースに変更が加わる場合である。

  3. 具体的には、本記事の静的アセットとリソースルートの比較を見よ。

  4. 本文中で言及した点について設定を行う場合は、nasa-gcn/remix-seoを見よ。またサーバーサイドでのみ実行するコードをhandle関数に実行したい場合は、Can't use server-side code to get sitemap entries · Issue #17 · nasa-gcn/remix-seoを参照せよ。

  5. これはRemixにおいてSplat Routeのpathが*として解釈されるからである。このことは、npx remix routesにおいて確認可能である。

  6. "post" スクリプトの動作については、scripts | npm Docsを参照せよ。

  7. Server React DOM APIs - Reactを見よ。

  8. Viteにおけるpublicディレクトリの扱いについては、Static Asset Handling | Viteを見よ。

  9. Pricing | Cloudflare Pages docsを見よ。また、Cloudflare Workersについても同様である。Static Assets | Cloudflare Workers docs