banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

Vercel におけるシングルページアプリケーション(SPA)動的 Meta タグの実践

この記事は、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 上での効果は以下の通りです:

image

Monorepo を使用して SPA アプリケーションを分離する#

SPA アプリケーションでは、すべてのルートが SEO を行う必要があるわけではなく、共有される必要があるページだけが SEO 最適化を必要とすることが多いです。例えば、Followアプリケーションでは、/shareルート以下のページだけが SEO 最適化を必要とします。

例えば: https://app.follow.is/share/users/innei に動的に挿入される meta タグは以下の通りです:

image

では、プロジェクトの分離作業を始めましょう。始める前に、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です。

image

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 万行のコードを移動および修正しました。

image

以前は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ファイルを作成し、メタデータを定義する関数をエクスポートします。

image

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です。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。