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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
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 を提供していません。特に注意すべきポイント:
nodejs_compat は万能ではない — process, Buffer, crypto などの基本 API は提供されますが、vm, child_process, fs などは未実装です。
DOM ポリフィルに注意 — happy-dom, jsdom などの DOM 実装は Node.js 固有の API に依存していることが多く、Edge ランタイムでは動作しない可能性があります。
ローカルと本番の差異 — next dev はローカルの Node.js で動くため問題は発生しません。問題が本番のみで起きる場合、ランタイムの違いを疑いましょう。
依存の依存を確認する — 直接の依存だけでなく、その依存が使っている API も確認が必要です。今回は @tiptap/html → happy-dom → vm.Script という2段階の依存でした。
まとめ
Cloudflare Workers で TipTap の HTML レンダリングが失敗する問題を、DOM 不要の純粋文字列シリアライザーで解決しました。Edge ランタイムを使用する場合は、依存ライブラリが使用している Node.js API を事前に確認し、必要に応じて代替実装を用意することが重要です。
この修正により、バンドルサイズが約 2MB 削減され、パフォーマンスも向上しました。「制約は創造の母」— Edge ランタイムの制限が、よりシンプルで効率的な実装を生み出すきっかけになりました。