すぬーぴー

Cloudflare Workers で @tiptap/html が動かない? — happy-dom の罠と純粋文字列シリアライザーによる解決

Next.js + Cloudflare Workers + TipTap という構成で CMS を構築していたところ、本番環境でのみ記事詳細ページが「記事が見つかりません」と表示される不具合に遭遇しました。ローカルでは完璧に動くのに、デプロイすると壊れる。この記事では、原因の特定からアーキテクチャレベルの解決までを、実際のコードとともに解説します。

TL;DR

  • 問題: @tiptap/html/server は happy-dom に依存し、happy-dom は vm.Script.runInContext を使用する。Cloudflare Workers はこの API を実装していない。

  • 解決: DOM 不要の純粋文字列ベース TipTap JSON → HTML シリアライザーを実装。バンドルサイズも約 2MB 削減。

システム構成

まず、今回の構成を整理します:

┌─────────────────┐     ┌──────────────────────┐     ┌─────────────────┐
│   Blog (SSG)    │────▶│   CMS API (Workers)  │────▶│    Supabase     │
│ Next.js + Pages │     │  Next.js + Workers   │     │  DB + Storage   │
└─────────────────┘     └──────────────────────┘     └─────────────────┘
  Cloudflare Pages       Cloudflare Workers            Supabase Cloud
                              │
                              ▼
                    ┌──────────────────┐
                    │  TipTap JSON →   │
                    │  HTML 変換       │
                    │  (ここが壊れた)    │
                    └──────────────────┘

ブログは Next.js の Static Site Generation(SSG)で構築され、Cloudflare Pages にデプロイ。ビルド時に CMS API を呼び出して静的 HTML を生成します。

CMS は Next.js アプリを opennextjs-cloudflare 経由で Cloudflare Workers にデプロイ。記事データは Supabase(DB にメタデータ、Storage に TipTap JSON)に保存しています。

症状:一覧は表示されるのに詳細だけ 404

ブログのトップページには記事一覧が正しく表示されます。しかし、記事をクリックすると「記事が見つかりません」と表示される。全ての記事で同じ症状です。

これは Next.js の notFound() が呼ばれていることを意味します。ページコンポーネントのコードを確認すると:

// blog/src/app/blog/[slug]/page.tsx
export default async function BlogPostPage({ params }) {
  try {
    const { slug } = await params;
    post = await getPostBySlug(slug);  // ← ここでエラー
  } catch {
    notFound();  // ← catch されて 404 ページが生成される
  }
}

つまり、getPostBySlug() が例外をスローしている。SSG ビルド時に CMS API が失敗すると、静的ページが「記事が見つかりません」の内容で生成されてしまいます。

原因の特定:API レスポンスの比較

一覧 API と詳細 API を直接叩いて比較しました:

# 一覧 API → 200 OK ✅
$ curl -H "X-API-KEY: $KEY" "$CMS_URL/api/v1/contents"
{"contents":[{"id":"b2e44bb4-...","title":"..."}], ...}

# 詳細 API → 500 Internal Server Error ❌
$ curl -H "X-API-KEY: $KEY" "$CMS_URL/api/v1/contents/b2e44bb4-..."
{"error":"Failed to render content"}

一覧 API は DB からメタデータのみ返すので成功。詳細 API は追加で Supabase Storage からコンテンツ取得 + TipTap HTML レンダリング を行うため、そこで失敗しています。

深掘り:エラーの連鎖を追う

CMS の詳細 API ルートハンドラを見ると、エラーは renderContentHtml() で発生しています:

リクエスト受信
     │
     ▼
① API キー検証      ✅ 成功
     │
     ▼
② DB からメタデータ取得  ✅ 成功
     │
     ▼
③ Storage からコンテンツ取得  ✅ 成功
     │
     ▼
④ renderContentHtml()    ❌ ここで例外!
     │
     ▼
catch → 500 "Failed to render content"

一時的にエラー詳細をレスポンスに含めてデプロイしたところ、真のエラーメッセージが判明:

{
  "error": "Failed to render content",
  "detail": "Error: [unenv] Script.runInContext is not implemented yet!"
}

根本原因:ランタイムの違い

ここからが本題です。エラーの連鎖を図解します:

renderContentHtml()
  └─▶ generateHTML()        ← @tiptap/html/server
        ├── チェック: process.versions.node が存在するか?
        │   └── Cloudflare Workers: process.versions.node = undefined
        │       └── ❌ "generateHTML can only be used in a Node environment"
        │
        └── (チェック通過後)
            └─▶ new Window()   ← happy-dom
                  └─▶ vm.Script.runInContext()
                        └── ❌ "[unenv] Script.runInContext is not implemented yet!"

2つの壁があります:

壁 1: process.versions.node チェック

@tiptap/html/server の generateHTML() には、実行環境が Node.js かどうかのチェックが入っています:

// @tiptap/html/server の内部コード
function generateHTML(doc, extensions) {
  const isNode = typeof process !== "undefined"
    && process.versions != null
    && process.versions.node != null;
  if (!isNode) {
    throw Error("generateHTML can only be used in a Node environment");
  }
  // ...
}

Cloudflare Workers は nodejs_compat フラグにより process オブジェクトは存在しますが、process.versions.node は undefined です。これは polyfill で回避できます:

// ⚠️ これだけでは不十分!
if (typeof process !== 'undefined' && process.versions && !process.versions.node) {
  process.versions.node = '20.0.0';
}

壁 2: happy-dom の vm.Script 依存(本当の問題)

壁 1 を突破しても、generateHTML() の内部で happy-dom の Window クラスがインスタンス化されます。happy-dom は内部で vm.Script.runInContext を使用しており、これは Cloudflare Workers の V8 アイソレート環境では実装されていません。

┌───────────────────────────────────────────────────┐
│               ランタイム比較                         │
├──────────────────┬──────────┬──────────────────────┤
│ API              │ Node.js  │ Cloudflare Workers   │
├──────────────────┼──────────┼──────────────────────┤
│ process          │ ✅       │ ✅ (nodejs_compat)   │
│ process.versions │ ✅       │ ✅ (nodejs_compat)   │
│ process.versions │          │                      │
│   .node          │ ✅       │ ❌ undefined         │
│ vm.Script        │ ✅       │ ❌ not implemented   │
│ window / DOM     │ ❌       │ ❌                   │
└──────────────────┴──────────┴──────────────────────┘

つまり、@tiptap/html には ブラウザ用ビルドとサーバー用ビルドの2種類がありますが、どちらも Cloudflare Workers では動作しません:

  • @tiptap/html(ブラウザ版): window が必要 → Workers にない

  • @tiptap/html/server(サーバー版): happy-dom → vm.Script が必要 → Workers にない

解決策:DOM に頼らないシリアライザー

TipTap の JSON ドキュメントは構造化されたデータです。DOM を使わずとも、JSON を直接 HTML 文字列に変換できます。

TipTap JSON の構造

interface TipTapDocument {
  type: 'doc'
  content: TipTapNode[]
}

interface TipTapNode {
  type: string                    // 'paragraph', 'heading', 'text', etc.
  attrs?: Record<string, unknown> // { level: 2 } for headings
  content?: TipTapNode[]          // 子ノード
  marks?: TipTapMark[]            // bold, italic, link, etc.
  text?: string                   // テキストノードの場合
}

// 例: 太字リンクを含む段落
{
  "type": "paragraph",
  "content": [{
    "type": "text",
    "text": "Click here",
    "marks": [
      { "type": "bold" },
      { "type": "link", "attrs": { "href": "https://example.com" } }
    ]
  }]
}

純粋文字列シリアライザーの実装

各ノードタイプを HTML タグにマッピングする再帰関数を実装します:

function escapeHtml(text: string): string {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}

function renderNode(node: TipTapNode): string {
  switch (node.type) {
    case 'text':
      return renderMarks(node.text ?? '', node.marks)
    case 'paragraph':
      return `<p>${renderChildren(node)}</p>`
    case 'heading': {
      const level = Math.min(Math.max(Number(node.attrs?.level) || 1, 1), 6)
      return `<h${level}>${renderChildren(node)}</h${level}>`
    }
    case 'bulletList':
      return `<ul>${renderChildren(node)}</ul>`
    case 'orderedList':
      return `<ol>${renderChildren(node)}</ol>`
    case 'listItem':
      return `<li>${renderChildren(node)}</li>`
    case 'codeBlock': {
      const lang = node.attrs?.language
      const cls = lang ? ` class="language-${escapeHtml(lang)}"` : ''
      return `<pre><code${cls}>${renderChildren(node)}</code></pre>`
    }
    case 'blockquote':
      return `<blockquote>${renderChildren(node)}</blockquote>`
    case 'horizontalRule':
      return '<hr>'
    case 'hardBreak':
      return '<br>'
    case 'image': {
      const src = node.attrs?.src
      if (typeof src !== 'string') return ''
      return `<img src="${escapeHtml(src)}" alt="${escapeHtml(node.attrs?.alt ?? '')}">`
    }
    default:
      return renderChildren(node)
  }
}

function renderChildren(node: TipTapNode): string {
  return (node.content ?? []).map(renderNode).join('')
}

マーク(テキスト装飾)の処理

function renderMarks(text: string, marks?: TipTapMark[]): string {
  if (!marks || marks.length === 0) return escapeHtml(text)

  let html = escapeHtml(text)
  for (const mark of marks) {
    switch (mark.type) {
      case 'bold':   html = `<strong>${html}</strong>`; break
      case 'italic': html = `<em>${html}</em>`; break
      case 'strike': html = `<s>${html}</s>`; break
      case 'code':   html = `<code>${html}</code>`; break
      case 'link': {
        const href = mark.attrs?.href
        if (typeof href === 'string' && isAllowedUrl(href)) {
          html = `<a href="${escapeHtml(href)}">${html}</a>`
        }
        break
      }
    }
  }
  return html
}

URL サニタイズ(セキュリティ)

CMS からの HTML をそのまま dangerouslySetInnerHTML で表示するため、XSS 対策として URL のプロトコルチェックが必須です:

const ALLOWED_PROTOCOLS = new Set(['http', 'https', 'mailto'])

function isAllowedUrl(value: string): boolean {
  // 制御文字を除去して正規化(java\nscript: → javascript: を検出)
  const normalized = value.replace(/[\x00-\x1f\x7f\s]/g, '')
  
  const schemeMatch = normalized.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/)
  if (!schemeMatch) return true // 相対 URL は許可
  
  return ALLOWED_PROTOCOLS.has(schemeMatch[1].toLowerCase())
}

// ❌ ブロックされる例:
// "javascript:alert(1)"      → scheme: javascript → 拒否
// "java\nscript:alert(1)"   → 正規化後: javascript: → 拒否
// "data:text/html,..."       → scheme: data → 拒否
//
// ✅ 許可される例:
// "https://example.com"      → scheme: https → 許可
// "/relative/path"           → 相対 URL → 許可
// "mailto:user@example.com"  → scheme: mailto → 許可

効果

バンドルサイズの削減

修正前: 11,239 KiB (gzip: 2,548 KiB)
修正後:  9,260 KiB (gzip: 2,176 KiB)
削減量: -1,979 KiB (-17.6%)

除去された依存:
  - @tiptap/html/server
  - happy-dom (~2MB)
  - vm polyfill (unenv)

検証結果

┌──────────────────────┬──────────┬──────────┐
│ 項目                  │ 修正前   │ 修正後    │
├──────────────────────┼──────────┼──────────┤
│ CMS 詳細 API         │ 500 ❌   │ 200 ✅   │
│ CMS テスト           │ 182/185  │ 185/185  │
│ ブログビルド          │ 失敗     │ 成功 ✅   │
│ ブログテスト          │ 7/7      │ 7/7 ✅   │
│ バンドルサイズ         │ 11.2 MB  │ 9.3 MB   │
│ Worker 起動時間       │ 24ms     │ 27ms     │
└──────────────────────┴──────────┴──────────┘

学び: Edge ランタイムの制約を意識する

Cloudflare Workers、Vercel Edge Functions、Deno Deploy などの Edge ランタイムは、Node.js の完全な API を提供していません。特に注意すべきポイント:

  1. nodejs_compat は万能ではない — process, Buffer, crypto などの基本 API は提供されますが、vm, child_process, fs などは未実装です。

  2. DOM ポリフィルに注意 — happy-dom, jsdom などの DOM 実装は Node.js 固有の API に依存していることが多く、Edge ランタイムでは動作しない可能性があります。

  3. ローカルと本番の差異 — next dev はローカルの Node.js で動くため問題は発生しません。問題が本番のみで起きる場合、ランタイムの違いを疑いましょう。

  4. 依存の依存を確認する — 直接の依存だけでなく、その依存が使っている API も確認が必要です。今回は @tiptap/html → happy-dom → vm.Script という2段階の依存でした。

まとめ

Cloudflare Workers で TipTap の HTML レンダリングが失敗する問題を、DOM 不要の純粋文字列シリアライザーで解決しました。Edge ランタイムを使用する場合は、依存ライブラリが使用している Node.js API を事前に確認し、必要に応じて代替実装を用意することが重要です。

この修正により、バンドルサイズが約 2MB 削減され、パフォーマンスも向上しました。「制約は創造の母」— Edge ランタイムの制限が、よりシンプルで効率的な実装を生み出すきっかけになりました。

← ホームに戻る