Cloudflare WorkersのWebAssembly制約をビルド時HTML変換で乗り越える

はじめに
Cloudflare Workers環境でShikiを使うと CompileError: WebAssembly.instantiate(): Wasm code generation disallowed by embedder が出て記事ページがクラッシュします。
ランタイムでMarkdown→HTML変換を行う設計を捨て、ビルド時に全変換を完了させる構造に移行することで解決できます。
Cloudflare Workersへのデプロイでこんなことありませんか?
- ローカルでは動くのに、Cloudflare Pagesにデプロイすると記事詳細ページだけ「Application Error」になる。
- シンタックスハイライトライブラリを別のものに替えても同じWASMエラーが出る。
- とりあえずシンタックスハイライトを無効にしたが、技術ブログとして致命的な状態が続いている。
この記事をお勧めしない人
- ローカル環境と本番環境の違いなんて、その場でググって解決すればいいと思っている人。
- シンタックスハイライトのためだけにビルドプロセスを複雑にするなんて、馬鹿げていると考える人。
- サーバーレス環境の制約は、単なる技術選定のミスであり、アーキテクチャで乗り越える課題ではないと考える人。
もし一つでも当てはまらないなら、読み進める価値があるかもしれません。
ライブラリを替え続けると
- Shikiを別のハイライターに替えても、内部でWASMを使っていれば同じエラーが出る。
- 「ライブラリを試す→エラー→別のライブラリを試す」のループで時間が溶ける。
- シンタックスハイライトを諦めると、技術ブログの可読性が下がり続ける。
ビルド時HTML変換という明るい未来
- この記事を読めば、Cloudflare WorkersのWASM制約を回避する「ビルド時変換」という設計思想が手に入る。
- 具体的には、
scripts/prebuild/generate-blog-posts.jsでShikiを事前初期化してからPromise.allで並列変換し、Workers環境はHTMLを配信するだけにする設計図を手に入れられる。 - この方法は、このブログ自身のシンタックスハイライト実装として実証済みで、21記事を数秒で変換しエラーゼロを達成した。
私も同じでした
このブログでも最初はloaderでShikiを呼び出し、記事詳細ページが Application Error になりました。AIは代替ライブラリを次々と提案しましたが、すべてWASMを内部で使っており同じエラーでした。scripts/prebuild/generate-blog-posts.js にビルド時変換を実装し、並列処理前に await getHighlighter() を一度呼び出すことで競合も解決しました。
📝 概要
Cloudflare Pages(Workers環境)にデプロイしたRemixブログで、記事詳細ページを開くと「Application Error!」が表示される問題に遭遇しました。
ローカル開発環境では正常に動作していたため、サーバーレス環境特有の制約が原因でした。
この記事で得られるもの:
- 実行環境による演算拒否 への対処法(ランタイム vs ビルド時の主権委譲)
- AIがループする根本原因 の理解(なぜライブラリ交換では解決しないのか)
- 21記事を数秒で変換し、エラーをゼロにした 実証済みのアーキテクチャ
AIは「別のライブラリを試しましょう」と提案し続けます。しかし、それは根本治療ではなく対症療法です。
根本原因はライブラリではなく、 Cloudflare Workersという実行環境の『思想』 にありました。
ここから先は、AIが絶対に提案しない 『実行タイミングの主権委譲』 という解決策の全貌と、
具体的な実装コード、並列処理の競合を乗り越えた技術的手順、そして実際のパフォーマンス測定結果を、すべて公開します。
⚠️ 問題の発見と症状
stg環境(テスト用の公開環境)にデプロイ後、記事一覧ページは表示されるものの、記事詳細ページ(例: /blog/welcome)を開くと「Application Error!」と表示されてしまいました。
エラーメッセージ:
CompileError: WebAssembly.instantiate(): Wasm code generation disallowed by embedder症状:
- ホーム画面: 正常表示
- ブログ一覧ページ: 正常表示
- ❌ 記事詳細ページ: Application Error
- ローカル開発環境では再現せず、デプロイ後にのみ発生
🔍 調査と試行錯誤のプロセス
仮説1: Cloudflare Workersの制約が原因ではないか?
まず、Cloudflare Workers環境の制約を確認しました。公式ドキュメントによると、 動的なWebAssembly(※)生成は許可されていない ことが判明。シンタックスハイライトライブラリはコードの色付けのために内部でWebAssembly(正規表現エンジン)を使っているため、これが原因でエラーになっている可能性が高いと推測しました。
判明した事実:
- Cloudflare Workersは事前コンパイルされたWASMのみサポート
- ランタイムでの動的WASM生成は禁止
- シンタックスハイライトライブラリは初期化時に動的にWASMを生成
※ WebAssembly (WASM): ウェブブラウザで高速に動くプログラムの形式。
仮説2: ブラウザ側での変換を試す
次に、サーバー側での処理を諦め、マークダウンをそのままブラウザに送り、ユーザーの画面でHTMLに変換する方法を検討しました。しかし、この方法には以下の問題がありました。
問題点:
- 初回表示が遅くなる(クライアント側で毎回変換)
- SEO的に不利(HTMLが初期状態では存在しない)
- ユーザー体験の低下
この方法はデメリットが大きいため、最終手段として保留しました。
仮説3: ビルド時にHTML変換するアプローチ
最終的に、「ビルド時にあらかじめマークダウンをHTMLに変換しておけば、サーバー(Workers環境)では完成したHTMLを配信するだけで済む」というアプローチに行き着きました。
メリット:
- Workers環境でWASMを使用しない
- 高速な初期表示(HTML配信のみ)
- SEO最適化
- ビルド時の通常環境ではシンタックスハイライトライブラリが正常動作
💡 根本原因の特定
調査の結果、根本原因は以下の3つの組み合わせでした。
- Workers環境のWASM制約 : Cloudflare Workersはセキュリティとパフォーマンスのため、動的なWebAssembly生成を禁止している。
- シンタックスハイライトライブラリの依存関係 : 色付けライブラリは内部でWebAssembly実装の正規表現エンジンに依存している。
- ランタイム変換の試み : アプリケーションが、ユーザーからのリクエスト時に(ランタイムで)マークダウンからHTMLへの変換を実行しようとしていた。
問題の本質:
サーバーレス環境の制約に対して、 処理の実行タイミング(ランタイム vs ビルド時) を見直す必要があったのです。
では、この問題をどう解決したのか。具体的な実装アプローチと、並列処理で遭遇した競合問題をどう乗り越えたか。その詳細な技術的手順と、実際のパフォーマンス測定結果を公開します。
🔧 解決策
プレビルドスクリプトの実装
scripts/prebuild/generate-blog-posts.js に、ビルド時にMarkdownをHTMLに変換するロジックを追加しました。
import { marked } from 'marked';
import sanitizeHtml from 'sanitize-html';
import { createHighlighter } from 'shiki/bundle/full';
// Shikiハイライターをシングルトン(※)で管理
let highlighter = null;
async function getHighlighter() {
if (!highlighter) {
console.log('⚡ Initializing Shiki highlighter...');
highlighter = await createHighlighter({
themes: ['github-dark'],
langs: ['javascript', 'typescript', 'html', 'css', 'markdown', 'bash', 'json', 'tsx', 'diff', 'yaml', 'xml'],
});
}
return highlighter;
}
async function convertMarkdownToHtml(markdown) {
const hl = await getHighlighter();
// ...(markedとShikiを連携させてHTMLに変換する処理)...
const rawHtml = await marked.parse(markdown);
return sanitizeHtml(rawHtml, { /* サニタイズ設定 */ });
}※ シングルトン : プログラム全体でインスタンス(実体)が1つしか作られないことを保証するデザインパターン。
並列処理の最適化
ぶつかった壁: 21記事を並列処理(Promise.all)すると、複数の処理が同時にgetHighlighter()を呼び出してしまい、リソースの競合が起きてハングアップしました。
解決方法: 並列処理を開始する前に、一度だけShikiの初期化処理を呼び出すように修正しました。
async function generateBlogPosts() {
try {
console.log('🚀 Starting blog posts generation...');
+
+ // 並列処理前にShikiを初期化(競合防止)
+ await getHighlighter();
// Markdownファイルを読み込む
const posts = await Promise.all(
markdownFiles.map(async (file) => {
// HTML変換処理(既に初期化済みのhighlighterを使用)
const htmlContent = await convertMarkdownToHtml(content);
return { slug, content: htmlContent, ... };
})
);効果:
- ハング問題を完全解決
- 並列処理の高速性を維持
- シングルトンパターンで1つのインスタンスのみ生成されることを保証
🎓 学んだこと・まとめ
技術的な学び
- 実行環境の制約を理解する : Cloudflare Workersは軽量・高速ですが、WebAssemblyの動的生成は禁止されています。制約を回避するには「実行タイミングをずらす」という発想が有効です。この「ビルド時変換」という発想は、ファイルシステムが使えないという問題を解決した時と同じ設計思想です。
- ビルド時 vs ランタイムのトレードオフ : ビルド時に重い処理を済ませることで、ユーザーアクセス時(ランタイム)のパフォーマンスを最大化できます。
- 並列処理とシングルトンの重要性 : 重い初期化処理を伴うリソースを並列処理で使う場合、処理の開始前に一度だけ初期化を行うことでリソースの競合を防げます。
なお、同じWASM制約はOGP画像生成でも発生しました。workers-ogライブラリのWASM問題はビルド時変換ではなくssr.external設定という別のアプローチで解決しています。
パフォーマンス結果
📊 ビルド時変換の結果:
- 最速: 1ms (welcome)
- 最長: 322ms (cloudflare-pages-deployment-challenge)
- 平均: 約30-40ms
- 合計: 21記事を数秒で変換完了今後のベストプラクティス
- サーバーレス環境の制約を事前確認する : ライブラリを選定する際は、デプロイ先の実行環境(Cloudflare Workers, AWS Lambdaなど)との互換性を確認することが重要です。
- ビルド時生成を積極的に活用する : 静的サイトジェネレーション(SSG)の考え方を応用し、ビルド時に可能な処理はできる限り前倒しで実行することで、パフォーマンスと安定性が向上します。
