この記事は、Follow のメインアプリケーションと外部ページの分離および SEO サポートという PR の一部技術的詳細を実現するためのもので、技術的な詳細が多いため、シリーズ記事の一つとなります。
前回の記事Vercel に OpenGraph 画像サービスをデプロイするでは、Vercel を利用して Node.js サーバーをデプロイする方法を紹介しました。
この記事では、このサーバーを利用してさらに多くのことを行います。その一つは、SPA アプリケーションの SEO を最適化し、生成した OG 画像情報を meta タグに追加することです。
また、これら 2 つのプロジェクトを同じドメインに同時にマウントし、Vercel の Rewrite を利用して実現する必要があります。
Node.js サーバーを使用して HTML をリバースプロキシする#
OpenGraph の初歩#
一般的なソーシャルメディアは、サイトの HTML ソースコードを取得し、meta タグの内容を取得して異なるコンテンツを表示しますが、これらのクローラーは JS レンダリング能力を持っていないことが多いため、Node.js サーバーを利用して SPA の HTML をリバースプロキシし、HTML 内に URL のパスに基づいて必要な meta 情報を動的に挿入する必要があります。
OpenGraph 情報は、ソーシャルメディアがデータを取得する際に最も一般的なタイプの一つです。典型的な OpenGraph の meta タグは以下のようになります:
><meta property="og:title" content="浮光掠影の毎日 | 静かな森" /><meta
property="og:description"
content="また半月が過ぎました。この数日間に記録する価値のある出来事は何だったのでしょうか。 一時的な興味 先月末、国服のハースストーンが再開されました。ずっと前に少しの間ハースストーンをプレイしていたことを思い出します。大学の時には、寮全体をそのゲームに引き込んでしまいましたが、その後はコードに夢中になり、ゲームをやめてしまいました。 再開された際の特典はかなりあり、昨年のすべてのカードをもらい、再びアカウントにログインして特典を受け取りました。数日プレイした後、新しい環境に徐々に慣れてきました。"
/><meta
property="og:image"
content="https://innei.in/og?data=%257B%2522type%2522%253A%2522note%2522%252C%2522nid%2522%253A%2522182%2522%257D"
/><meta property="og:type" content="article" /><meta
name="twitter:card"
content="summary_large_image"
/><meta name="twitter:title" content="浮光掠影の毎日" /><meta
name="twitter:description"
content="また半月が過ぎました。この数日間に記録する価値のある出来事は何だったのでしょうか。 一時的な興味 先月末、国服のハースストーンが再開されました。ずっと前に少しの間ハースストーンをプレイしていたことを思い出します。大学の時には、寮全体をそのゲームに引き込んでしまいましたが、その後はコードに夢中になり、ゲームをやめてしまいました。 再開された際の特典はかなりあり、昨年のすべてのカードをもらい、再びアカウントにログインして特典を受け取りました。数日プレイした後、新しい環境に徐々に慣れてきました。"
/><meta
name="twitter:image"
content="https://innei.in/og?data=%257B%2522type%2522%253A%2522note%2522%252C%2522nid%2522%253A%2522182%2522%257D"
/>
X 上での効果は以下の通りです:
Monorepo を使用して SPA アプリケーションを分離する#
SPA アプリケーションでは、すべてのルートが SEO を行う必要があるわけではなく、共有される必要があるページだけが SEO 最適化を必要とすることが多いです。例えば、Followアプリケーションでは、/share
ルート以下のページだけが SEO 最適化を必要とします。
例えば: https://app.follow.is/share/users/innei
に動的に挿入される meta タグは以下の通りです:
では、プロジェクトの分離作業を始めましょう。始める前に、SPA アプリケーションがどのように機能するかを理解する必要があります。Vite でコンパイルされた典型的な SPA アプリケーションのディレクトリ構造は以下の通りです:
dist
├── assets
│ ├── index.12345678.css
│ ├── index.12345678.js
│ └── index.12345678.js.map
├── index.html
ここでindex.html
は私たちの SPA アプリケーションのエントリーファイルで、ブラウザが/
ルートにアクセスするとこのファイルが読み込まれ、その後 JS ファイルに基づいてページが動的にレンダリングされます。
このファイルを Node.js を使用してリバースプロキシし、URL のパスに基づいて meta タグを動的に挿入すれば良いのです。
SPA プロジェクトを分離するために、別の SPA アプリケーションを作成し、SEO を行う必要があるルートをこのプロジェクトに配置します。この過程で、多くの共有コンポーネントをコピーすることになるかもしれませんが、それも改造が必要です。Monorepo を利用することで、この問題をうまく解決できます。
例えば、元のプロジェクトでは、apps/renderer
が完全な SPA アプリケーションですが、ここで新しいアプリをserver
という名前で作成し、ディレクトリ位置をapps/server
にします。これはリバースプロキシサーバーです。フロントエンドコードを格納するためのディレクトリを作成します。例えば、apps/server/client
です。
apps/renderer
と同じディレクトリ構造を再現し、必要なルートをこのプロジェクトに配置します。共通モジュール、例えばコンポーネントやユーティリティ関数をapps/renderer
から抽出し、packages
ディレクトリに移動します。このプロセスは段階的に行うことができます。リファクタリング中に、コミットが大きくなりすぎて長時間の停滞を引き起こす大量の衝突を避けるために、まずコードをコピーして共通モジュールをapps/renderer
からpackages
に抽出し、元のコードの参照関係を変更せずに、新しいアプリケーションでpackages
の参照を使用します。例えば、packages/components
ディレクトリを作成し、apps/renderer/src/components
から一部のコンポーネントを抽出します。
パッケージのpackage.json
ファイルを作成します。例えば:
{
"name": "@follow/components",
"version": "0.0.1",
"private": true,
"sideEffects": false,
"exports": {
"./tailwind": "./assets/index.css",
"./modules/*": "./src/modules/*",
"./common/*": "./src/common/*",
"./icons/*": "./src/icons/*",
"./ui/*": "./src/ui/*",
"./providers/*": "./src/providers/*",
"./hooks/*": "./src/hooks/*",
"./atoms/*": "./src/atoms/*",
"./dayjs": "./src/utils/dayjs.ts",
"./utils/*": "./src/utils/*"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"dependencies": {}
}
このパッケージはコンパイルする必要がないため、ソースコードをそのままエクスポートできます。ここでは、exports
フィールドを使用してエクスポートするファイルを指定し、複数のディレクトリにワイルドカードでエクスポートします。共通のスタイルも共通の CSS ファイルとしてエクスポートできます。例えば、TailwindCSS を使用します。
@import './colors.css'; /* カスタムカラー */
@import './tailwind.css'; /* TailwindCSS */
@import './font.css'; /* カスタムフォント */
@tailwind base;
@tailwind components;
@tailwind utilities; /* その他 */
apps/server
でこのパッケージを参照します:
pnpm i @follow/components@workspace:*
この時点で、apps/server/client
でこのパッケージを直接参照して、ラップされたコンポーネントを使用できます:
例えば:
import { Button } from '@follow/components/ui/button/index.js'
そういえば、TailwindCSS を使用する場合、content
フィールドを少し変更する必要があるかもしれません。
export default resolveConfig({
content: [
'./client/**/*.{ts,tsx}',
'./index.html',
'./node_modules/@follow/components/**/*.{ts,tsx}', // この行を追加
],
})
コンポーネントを抽出し、共通モジュールをラップするプロセスは非常に長く、苦痛を伴うものです。Follow の改造において、私は合計で 8 つのパッケージを抽出し、約 1 万行のコードを移動および修正しました。
以前はapps/renderer
のコードをコピーしていたため、改造が完了した後、renderer
内のすべてのコード参照を一度に修正する必要があります。例えば、~/components
を@follow/components
に変更し、renderer
内のすでに移行されたコンポーネントやコードをすべて削除します。
Node.js サーバーを使用して HTML をリバースプロキシする#
この部分は 2 つの部分に分かれています。一つは、開発環境下での Vite Dev Server と Node.js サーバーのバインディングを処理し、Vite Dev Server が生成した HTML を修正して meta タグを注入することです。もう一つは、製品環境下でコンパイルされた index.html ファイルを処理することです。
まず、開発環境についてですが、ここでは Fastify を Node.js サーバーの例として使用します。
以下の方法で Vite Dev Server とバインディングを実現できます。
let globalVite: ViteDevServer
export const registerDevViteServer = async (app: FastifyInstance) => {
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
configFile: resolve(root, 'vite.config.mts'), // vite configのパス
envDir: root,
})
globalVite = vite
// @ts-ignore
app.use(vite.middlewares)
return vite
}
アプリの初期化時に:
const app = Fastify({})
app.register(fastifyRequestContext)
await app.register(middie, {
hook: 'onRequest',
})
if (isDev) {
const devVite = require('./src/lib/dev-vite')
await devVite.registerDevViteServer(app)
}
HTML を処理するためのルートを作成します:
import { parseHTML } from 'linkedom'
app.get('*', async (req, reply) => {
const url = req.originalUrl
const root = resolve(__dirname, '../..')
const vite = require('../lib/dev-vite').getViteServer()
try {
let template = readFileSync(
path.resolve(root, vite.config.root, 'index.html'), // vite dev使用のindex.htmlのパス
'utf-8',
)
template = await vite.transformIndexHtml(url, template) // viteを使用して変換
const { document } = parseHTML(template)
reply.type('text/html')
reply.send(document.toString())
} catch (e) {
vite.ssrFixStacktrace(e)
reply.code(500).send(e)
}
})
これで HTML のリバースプロキシが実現しました。
Meta タグの動的挿入#
上記の基礎の上に、*
ルートを少し修正します:
app.get('*', async (req, reply) => {
const url = req.originalUrl
const root = resolve(__dirname, '../..')
const vite = require('../lib/dev-vite').getViteServer()
try {
let template = readFileSync(
path.resolve(root, vite.config.root, 'index.html'),
'utf-8',
)
template = await vite.transformIndexHtml(url, template)
const { document } = parseHTML(template)
await injectMetaToTemplate(document, req, reply) // ここでHTMLにmetaタグを注入
reply.type('text/html')
reply.send(document.toString())
} catch (e) {
vite.ssrFixStacktrace(e)
reply.code(500).send(e)
}
})
Meta
の型を定義します:
interface MetaTagdata {
type: 'meta'
property: string
content: string
}
interface MetaOpenGraph {
type: 'openGraph'
title: string
description?: string
image?: string | null
}
interface MetaTitle {
type: 'title'
title: string
}
export type MetaTag = MetaTagdata | MetaOpenGraph | MetaTitle
injectMetaToTemplate
関数を実装します:
async function injectMetaToTemplate(
document: Document,
req: FastifyRequest,
res: FastifyReply,
) {
const injectMetadata = await injectMetaHandler(req, res).catch((err) => {
// injectMetadata内でURLのパスに基づいて異なるmetaタグを処理
if (isDev) {
throw err
}
return []
})
if (!injectMetadata) {
return document
}
for (const meta of injectMetadata) {
switch (meta.type) {
case 'openGraph': {
const $metaArray = buildSeoMetaTags(document, { openGraph: meta })
for (const $meta of $metaArray) {
document.head.append($meta)
}
break
}
case 'meta': {
const $meta = document.createElement('meta')
$meta.setAttribute('property', meta.property)
$meta.setAttribute('content', xss(meta.content))
document.head.append($meta)
break
}
case 'title': {
if (meta.title) {
const $title = document.querySelector('title')
if ($title) {
$title.textContent = `${xss(meta.title)} | Follow`
} else {
const $head = document.querySelector('head')
if ($head) {
const $title = document.createElement('title')
$title.textContent = `${xss(meta.title)} | Follow`
$head.append($title)
}
}
}
break
}
}
}
return document
}
import xss from 'xss'
export function buildSeoMetaTags(
document: Document,
configs: {
openGraph: {
title: string
description?: string
image?: string | null
}
},
) {
const openGraph = {
title: xss(configs.openGraph.title),
description: xss(configs.openGraph.description ?? ''),
image: xss(configs.openGraph.image ?? ''),
}
const createMeta = (property: string, content: string) => {
const $meta = document.createElement('meta')
$meta.setAttribute('property', property)
$meta.setAttribute('content', content)
return $meta
}
return [
createMeta('og:title', openGraph.title),
createMeta('og:description', openGraph.description),
createMeta('og:image', openGraph.image),
createMeta('twitter:card', 'summary_large_image'),
createMeta('twitter:title', openGraph.title),
createMeta('twitter:description', openGraph.description),
createMeta('twitter:image', openGraph.image),
]
}
injectMetaHandler
関数を実装します:
import { match } from 'path-to-regexp'
export async function injectMetaHandler(
req: FastifyRequest,
res: FastifyReply,
): Promise<MetaTag[]> {
const apiClient = createApiClient()
const upstreamOrigin = req.requestContext.get('upstreamOrigin')
const url = req.originalUrl
for (const [pattern, handler] of Object.entries(importer)) {
const matchFn = match(pattern, { decode: decodeURIComponent })
const result = matchFn(url)
if (result) {
const parsedUrl = new URL(url, upstreamOrigin)
return await handler({
// 必要に応じてここでコンテキストを渡すことができます
params: result.params as Record<string, string>,
url: parsedUrl,
req,
apiClient,
origin: upstreamOrigin || '',
setStatus(status) {
res.status(status)
},
setStatusText(statusText) {
res.raw.statusMessage = statusText
},
throwError(status, message) {
throw new MetaError(status, message)
},
})
}
}
return []
}
injectMetaHandler
内では、path-to-regexp
を使用して/share/feeds/:id
のようなパスをマッチさせ、importer マップから対応する処理関数を見つけて、MetaTag
配列を返します。
importer マップは、SPA アプリケーションのルートに基づいて自動生成されるべきです。例えば:
import i1 from '../client/pages/(main)/share/feeds/[id]/metadata'
import i2 from '../client/pages/(main)/share/lists/[id]/metadata'
import i0 from '../client/pages/(main)/share/users/[id]/metadata'
export default {
'/share/users/:id': i0,
'/share/feeds/:id': i1,
'/share/lists/:id': i2,
}
Follow では、SPA のルート定義はファイルシステムツリーに基づいて生成されるため、この特性に基づいてヘルパーウォッチャーを作成できます。
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import chokidar from 'chokidar'
import { glob } from 'glob'
const __dirname = dirname(fileURLToPath(import.meta.url))
async function generateMetaMap() {
const files = await glob('./client/pages/(main)/**/metadata.ts', {
cwd: path.resolve(__dirname, '..'),
})
const imports: string[] = []
const routes: Record<string, string> = {}
files.forEach((file, index) => {
const routePath = file
.replace('client/pages/(main)', '')
.replace('/metadata.ts', '')
.replaceAll(/\[([^\]]+)\]/g, ':$1')
const importName = `i${index}`
imports.push(`import ${importName} from "../${file.replace('.ts', '')}"`)
routes[routePath] = importName
})
const content =
'// このファイルは`pnpm run meta`によって生成されました\n' +
`${imports.join('\n')}\n
export default {
${Object.entries(routes)
.map(([route, imp]) => ` "${route}": ${imp},`)
.join('\n')}
}
`
const originalContent = await fs.readFile(
path.resolve(__dirname, '../src/meta-handler.map.ts'),
'utf-8',
)
if (originalContent === content) return
await fs.writeFile(
path.resolve(__dirname, '../src/meta-handler.map.ts'),
content,
'utf-8',
)
console.info('メタマップが正常に生成されました!')
}
async function watch() {
const watchPath = path.resolve(__dirname, '..', './client/pages/(main)')
console.info('メタデータファイルを監視中...')
await generateMetaMap()
const watcher = chokidar.watch(watchPath, {
ignoreInitial: false,
})
watcher.on('add', () => {
console.info('メタデータファイルが追加/変更されました。マップを再生成中...')
generateMetaMap()
})
watcher.on('unlink', () => {
console.info('メタデータファイルが削除されました。マップを再生成中...')
generateMetaMap()
})
watcher.on('change', () => {
console.info('メタデータファイルが変更されました。マップを再生成中...')
generateMetaMap()
})
process.on('SIGINT', () => {
watcher.close()
process.exit(0)
})
}
if (process.argv.includes('--watch')) {
watch().catch(console.error)
} else {
generateMetaMap().catch(console.error)
}
これで、ルートの下にmetadata.ts
ファイルを作成し、メタデータを定義する関数をエクスポートします。
import { defineMetadata } from '~/meta-handler'
export default defineMetadata(
async ({ params, apiClient, origin, throwError }) => {
const listId = params.id
const list = await apiClient.lists.$get({ query: { listId } })
const { title, description } = list.data.list
return [
{
type: 'openGraph',
title: title || '',
description: description || '',
image: `${origin}/og/list/${listId}`,
},
{
type: 'title',
title: title || '',
},
]
},
)
これで、開発環境下で Vite Dev Server のリバースプロキシを実現し、動的に meta タグを挿入することができました。次は、製品環境の違いを処理します。
製品環境下の HTML リバースプロキシ#
製品環境では、リバースプロキシするのはコンパイルされた HTML ファイルです。
app.get('*', async (req, reply) => {
const template = readFileSync(
path.resolve(root, '../dist/index.html'), // ここはコンパイルされたHTMLファイル
'utf-8',
)
const { document } = parseHTML(template)
await injectMetaToTemplate(document, req, reply)
reply.type('text/html')
reply.send(document.toString())
})
ファイルシステムを直接置き換える方法は、Vercel のようなプラットフォームではうまく機能しないかもしれません。なぜなら、コンパイルされた成果物は API 呼び出し環境に存在しないため、Vercel にデプロイするとこのファイルが見つからないという問題が発生します。
コンパイル後の HTML 文字列を直接パッケージする方法でこの問題を解決できます。
import { mkdirSync } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'
mkdirSync(path.join(__dirname, '../.generated'), { recursive: true })
async function generateIndexHtmlData() {
const indexHtml = await fs.readFile(
path.join(__dirname, '../dist/index.html'),
'utf-8',
)
await fs.writeFile(
path.join(__dirname, '../.generated/index.template.ts'),
`export default ${JSON.stringify(indexHtml)}`,
)
}
async function main() {
await generateIndexHtmlData()
}
main()
上記のスクリプトでは、SPA プロジェクトがコンパイルされた後、コンパイルされた HTML ファイルを読み込み、.generated
ディレクトリに書き込み、HTML 文字列をエクスポートします。
製品環境下のリバースプロキシルートロジックを修正します。
app.get('*', async (req, reply) => {
const template = require('../../.generated/index.template').default
const { document } = parseHTML(template)
await injectMetaToTemplate(document, req, reply)
reply.type('text/html')
reply.send(document.toString())
})
次に、ビルドプロセスを修正します:
cross-env NODE_ENV=production vite build && tsx scripts/prepare-vercel-build.ts && tsup
サーバーサイドの
tsup
コンパイルについては、Vercel に OpenGraph 画像サービスをデプロイするで紹介されています。
これで、異なる環境の処理ロジックが 2 つできましたので、判断を行います:
export const globalRoute = isDev ? devHandler : prodHandler
Vercel へのデプロイ#
これで、Vercel にデプロイできます。同じドメイン上に 2 つのアプリケーションをマウントする必要があります。私たちのメインアプリケーションは元のアプリで、Vercel 上に新しいアプリケーションを作成する必要があります。このアプリは HTML をリバースプロキシするための Node.js サービスです。
現在、元のアプリを app1 と呼び、新しいアプリを app2 と呼びます。
実現する必要がある URL ルーティングルールは:
/share/* -> app2
/* -> app1
https://cdn.jsdelivr.net/npm/@innei/react-cdn-components@latest/excalidraw/follow/vercel-rewrite-domain.json
app2 内でvercel.json
を作成または修正します:
{
"rewrites": [
{
"source": "/((?!external-dist|dist-external).*)", // すべてのリクエストをVercelのAPIルートにリライトします。APIについては、![VercelにOpenGraph画像サービスをデプロイする](https://innei.in/posts/tech/deploy-an-opengraph-image-service-on-vercel)で確認できます。
"destination": "/api"
}
]
}
app1 内でvercel.json
を作成または修正します:
{
"rewrites": [
{
"source": "/share/:path*",
"destination": "https://<domain>/share/:path*" // アドレスを修正
},
{
"source": "/og/:path*",
"destination": "https://<domain>/og/:path*"
},
{
"source": "/((?!assets|vendor/).*)",
"destination": "/index.html"
}
],
"headers": []
}
このように修正することで、/share
のパスにアクセスすると app2 に正しくリライトされますが、app2 のリソースファイルはすべて 404 になります。app1 のコンパイル成果物もassets
パスにあるため、両者が衝突しないように、app2 には別のパス、例えばdist-external
を設定します。
app2 のvite.config.ts
を修正します:
import { resolve } from 'node:path'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default () => {
return defineConfig({
build: {
rollupOptions: {
output: {
assetFileNames: 'dist-external/[name].[hash].[ext]',
chunkFileNames: 'dist-external/[name].[hash].js',
entryFileNames: 'dist-external/[name].[hash].js',
},
},
},
plugins: [react()],
})
}
app1 内でvercel.json
を修正します:
{
"rewrites": [
{
"source": "/dist-external/:path*",
"destination": "https://<domain>/dist-external/:path*"
},
{
"source": "/((?!assets|vendor|locales|dist-external/).*)",
"destination": "/index.html"
}
]
}
長くなったため、他の詳細については今後の分解をお待ちください。
この記事はMix Spaceによって xLog に同期更新されました。元のリンクはhttps://innei.in/posts/tech/dynamic-meta-tags-for-spa-on-vercelです。