他プロダクトでの応用を目的とした設計・実装ドキュメント
本ドキュメントは、Flopsプロジェクトで実装した以下の2つの主要機能について、設計思想、利用ライブラリ、失敗談、ベストプラクティスをまとめたものです:
- 独自記法のParser&再生機能 - テキストベースの記法をパースしてインタラクティブに描画・再生
- OGP画像生成機能 - 動的にOpen Graph画像を生成してSNS共有を最適化
テキスト入力が正規データ源という設計原則を採用しています。
ユーザー入力テキスト → Parser → 内部状態 → UI表示
↑ ↓
└─────── テキスト生成 ←─────┘
利点:
- 状態の整合性保証(UIからの直接変更を禁止)
- デバッグが容易(テキストを見れば状態がわかる)
- 共有・保存が簡単(テキストをそのまま保存)
- バージョン管理と差分表示が可能
すべての状態遷移は新しいオブジェクトを生成します。既存状態を変更しません。
// ❌ 悪い例:直接変更
state.players.push(newPlayer);
state.pot += betAmount;
// ✅ 良い例:新しいオブジェクト生成
const newState = {
...state,
players: [...state.players, newPlayer],
pot: state.pot + betAmount,
};利点:
- Undo/Redoの実装が容易(状態のスナップショット保持)
- バグの原因特定が容易(状態変更の追跡可能)
- Reactivity(Svelte/React等)との相性が良い
packages/
├── domain/ # 型定義・スキーマ(依存なし)
├── engine/ # ビジネスロジック(domain依存)
├── parser/ # テキスト解析(domain, engine依存)
└── utils/ # 共有ユーティリティ
apps/
├── frontend/ # SvelteKit UI
└── backend/ # Hono API
依存関係の方向: domain ← engine ← parser ← frontend
循環依存を防ぐため、下位パッケージは上位パッケージを参照しません。
入力テキスト
↓
┌─────────────────┐
│ Lexer │ トークン分割
└─────────────────┘
↓
┌─────────────────┐
│ Pattern Matchers│ 優先度付きマッチング
└─────────────────┘
↓
┌─────────────────┐
│ GameStateBuilder│ 状態オブジェクト構築
└─────────────────┘
↓
┌─────────────────┐
│ValidationPipeline│ セマンティック検証
└─────────────────┘
↓
GameState
// 各マッチャーは優先度順に試行される
const matchers = [
{ name: 'game-format', match: matchGameFormat, priority: 1 },
{ name: 'board', match: matchBoard, priority: 2 },
{ name: 'position-action', match: matchPositionAction, priority: 3 },
{ name: 'stack', match: matchStack, priority: 4 },
{ name: 'comment', match: matchComment, priority: 5 },
];
// マッチ結果の型
interface MatchResult {
matched: boolean;
type: 'game-format' | 'board' | 'action' | ...;
data: ParsedData;
remaining: string; // 未消費の入力
}ポイント:
- 各マッチャーは純粋関数として実装
- 優先度を明確に定義し、曖昧さを排除
- 部分マッチ時は
remainingで残りを返す
// リアルタイム編集対応のため、行単位で状態更新可能
export function updateGameStateFromParsedLine(
currentState: GameState,
parsedLine: ParsedLineResult,
lineNumber: number
): GameState {
// 1. 既存行の検証
// 2. 差分計算
// 3. 新しいGameState生成
return { ...currentState, /* 更新内容 */ };
}| ライブラリ | 用途 | 選定理由 |
|---|---|---|
| Zod | スキーマ定義・バリデーション | TypeScript統合、エラーメッセージのカスタマイズ性 |
| Svelte Store | 状態管理(フロントエンド) | Svelteとの相性、シンプルなAPI |
問題: Parser、Engine、Frontend で別々のGameState定義が存在し、型変換が必要だった
// ❌ 問題のあった構造
packages/parser/src/types/game-state.ts // ParserGameState
packages/engine/src/types/game-state.ts // EngineGameState
apps/frontend/src/lib/types.ts // UIGameState教訓:
@flops/domainパッケージに型を一元化- 変換アダプター層を排除し、単一の型を全レイヤーで共有
問題: 一部のServiceがクラスで実装され、関数型コードと混在
教訓:
- 全ロジックを純粋関数で実装
- 依存性はカリー化またはパラメータ渡しで解決
// ✅ 関数型アプローチ
const createAuthService = (db: Database) => ({
login: async (email: string) => { ... },
});問題: 最終アクションと完了フラグを同じステップに設定したため、最終アクションが表示されずにショーダウンに遷移
教訓:
- アクションステップと完了ステップは分離する
- 状態遷移の単位を明確に定義
// ✅ 修正後
// アクションステップ(全アクション)
const actionSteps = actions.map(a => ({ type: 'action', action: a }));
// 完了ステップ(最後に追加)
if (isComplete) {
actionSteps.push({ type: 'completion', outcome });
}問題: UI入力とパース結果の双方向バインディングで状態の整合性が崩れた
教訓:
- 単方向データフローを徹底
- UIはテキスト入力のみを受け付け、状態はParserからのみ更新
- ドメイン型を独立パッケージに分離
- Parser → State → UI の単方向フロー設計
- Immutable更新パターンの採用
- Pattern Matcherの優先度定義
- インクリメンタル更新対応(リアルタイム編集向け)
- 検証エラーの蓄積と詳細メッセージ
WASM前提の技術選定を行いました。Node.js依存のライブラリ(Sharp等)はWorkers環境で動作しないため、WASMベースの代替を選択。
シングルトン + 遅延初期化でWASMモジュールを管理し、コールドスタートを最小化。
let wasmInitialized = false;
let wasmInitPromise: Promise<void> | null = null;
async function initResvgWasm(): Promise<void> {
// 初期化済みならスキップ
if (wasmInitialized) return;
// 別リクエストが初期化中なら待機
if (wasmInitPromise) {
await wasmInitPromise;
return;
}
// このリクエストが初期化
wasmInitPromise = (async () => {
await initWasm(wasmBytes);
wasmInitialized = true;
})();
await wasmInitPromise;
}データ抽出 → HTMLテンプレート → Satori(SVG) → resvg-wasm(PNG)
| ステップ | ライブラリ | 役割 |
|---|---|---|
| HTML→SVG | satori (0.12.2) | ReactコンポーネントスタイルのHTMLをSVGに変換 |
| HTML解析 | satori-html (0.3.2) | HTML文字列をSatori互換形式に変換 |
| SVG→PNG | @resvg/resvg-wasm (2.6.2) | RustベースのSVGレンダラー(WASM版) |
| フォント | subset-font | フォントサブセット化(ビルド時) |
// apps/frontend/src/routes/api/og/[code]/+server.ts
export const GET: RequestHandler = async ({ params, fetch, url }) => {
const code = params.code?.replace(/\.png$/, '');
// 1. バックエンドAPIからデータ取得
const handData = await fetchHandData(code);
// 2. テキストをパースしてGameState取得
const gameState = await parseTextToGameState(handData.content);
// 3. OGP用データ抽出
const ogpOptions = extractOGPOptions(gameState);
// 4. フォント読み込み(キャッシュ済み)
const fontData = await loadFont(fetch, url.origin);
// 5. 画像生成
const pngBytes = await generateOGPImage(ogpOptions, fontData, { fetch, origin: url.origin });
// 6. キャッシュヘッダー付きで返却
return new Response(pngBytes, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
};ポイント:
[code].png形式でURLをSNSフレンドリーに- 生成済み画像は1年間キャッシュ(
immutable)
Satoriの制約:
- Flexboxのみ(Grid非対応)
- インラインスタイルのみ(CSS非対応)
- 限定的なCSSプロパティ
export function buildOGPTemplate(options: OGPImageOptions): string {
return `
<div style="
display: flex;
flex-direction: column;
width: 1200px;
height: 630px;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
font-family: 'Noto Sans JP';
">
<!-- Hero Section -->
<div style="display: flex; padding: 40px;">
${renderHeroCards(options.heroCards)}
<span style="color: #888;">vs</span>
${renderOpponents(options.opponents)}
</div>
<!-- Board Section -->
<div style="display: flex; justify-content: center;">
${renderBoard(options.board)}
</div>
<!-- Action Timeline -->
<div style="display: flex;">
${renderActionsByStreet(options.actionsByStreet)}
</div>
</div>
`;
}問題: Noto Sans JP全体は4.44MBで、Workers環境では読み込みが遅い
解決策: subset-fontでポーカー表示に必要な文字のみ抽出
// ビルドスクリプト
const subset = await subsetFont(fullFont, {
targetChars: [
// 英数字
...'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
// カードスート
...'♠♥♦♣',
// 記号
...'.,!?-—:;()[]{}',
],
});
// 結果: 4.44MB → 50.71KB (98.9%削減)Workers環境ではリソース制限があるため、各ステップにタイムアウトを設定。
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ms);
return Promise.race([
promise,
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`${label} timed out`)), ms);
}),
]).finally(() => clearTimeout(timeoutId));
}
// 使用例
const fontData = await withTimeout(loadFont(fetch, origin), 8_000, 'Font load');
const pngBytes = await withTimeout(generateOGPImage(...), 20_000, 'OGP generation');問題: 最初にSharpを選択したが、Cloudflare Workersで動作しない
教訓:
- Edge/Workers環境を意識したライブラリ選定
- WASM版があるかを事前確認
- resvg-wasmは純Rust→WASMで環境非依存
問題: 複数リクエストが同時にWASM初期化を試み、「Already initialized」エラー
教訓:
- シングルトンPromiseパターンで同時初期化を防止
- 「Already initialized」エラーは無視して続行
// ✅ 競合防止
if (wasmInitPromise) {
await wasmInitPromise; // 他のリクエストを待機
return;
}
wasmInitPromise = initializeWasm();問題: Vite開発環境とCloudflare Workers本番環境でフォントURLの解決方法が異なる
教訓:
- 相対パスを優先し、失敗時に絶対URLにフォールバック
- SvelteKitの
event.fetchを使用(内部解決が効く)
// ✅ 環境対応
let response: Response;
try {
response = await fetch('/fonts/NotoSansJP.ttf');
} catch {
response = await fetch(new URL('/fonts/NotoSansJP.ttf', origin).toString());
}問題: GIF/PNG export用にCanvas専用レンダラーを実装したが、レイアウトの動的複雑性により二重管理が非現実的に
Issue #19 調査結果:
✅ 技術的成功: Canvas描画、PNG生成は動作
❌ 課題: レイアウトの動的複雑性(straddle、responsive、動的プレイヤー数)
❌ 保守コスト: PokerTable.svelteとCanvas版の二重管理が非現実的
教訓:
- 既存UIコンポーネントを画像化するアプローチを優先
- Satori + resvg-wasmならHTMLテンプレートをそのまま画像化可能
- Canvas専用レンダラーは「最終手段」として検討
| 最適化項目 | 効果 |
|---|---|
| フォントサブセット化 | 4.44MB → 50.71KB (98.9%削減) |
| WASMシングルトン | 再初期化回避 |
| キャッシュヘッダー | CDN活用(1年キャッシュ) |
| タイムアウト保護 | 無限待機防止 |
| プリフロップfoldフィルタ | 視覚的ノイズ削減 |
- Edge/Workers環境対応ライブラリの選定(WASM版確認)
- WASM初期化のシングルトンパターン実装
- フォントサブセット化(必要文字のみ抽出)
- タイムアウト管理の実装
- キャッシュ戦略の設計(immutableヘッダー)
- HTMLテンプレートはFlexbox + インラインスタイルのみ
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'
# バージョン管理は catalog で一元化
catalog:
typescript: ^5.0.0
svelte: ^5.0.0
zod: 4.1.5利点:
- 依存バージョンの一元管理
- パッケージ間の型共有が容易
- ビルド順序の自動解決(pnpm + tsup)
- クラス禁止 - 全ロジックを純粋関数で実装
- Immutable更新 - スプレッド演算子による新規オブジェクト生成
- Result型 - 例外の代わりに
{ success, data, error }パターン - Pipe関数 - 処理の合成と可読性向上
// AAA (Arrange-Act-Assert) パターン
describe('parseFlopsNotation', () => {
it('should parse basic preflop action', () => {
// Arrange
const input = 'BTN r2.5 AhKs';
// Act
const result = parseFlopsNotation(input);
// Assert
expect(result.success).toBe(true);
expect(result.data.action.type).toBe('raise');
});
});Frontend: Cloudflare Pages
├── SvelteKit + adapter-cloudflare
└── OGP画像生成(サーバーサイド)
Backend: Cloudflare Workers
├── Hono (軽量HTTPフレームワーク)
├── D1 (SQLiteベースDB)
└── Drizzle ORM (型安全)
| ライブラリ | バージョン | 用途 |
|---|---|---|
| zod | 4.1.5 | スキーマ定義・バリデーション |
| typescript | ^5.0.0 | 型システム |
| ライブラリ | バージョン | 用途 |
|---|---|---|
| satori | 0.12.2 | HTML→SVG変換 |
| satori-html | 0.3.2 | HTML解析 |
| @resvg/resvg-wasm | 2.6.2 | SVG→PNG変換 |
| subset-font | 2.4.0 | フォントサブセット化 |
| ライブラリ | バージョン | 用途 |
|---|---|---|
| svelte | ^5.0.0 | UIフレームワーク |
| @sveltejs/kit | ^2.22.0 | フルスタックフレームワーク |
| tailwindcss | ^4.0.0 | スタイリング |
| ライブラリ | バージョン | 用途 |
|---|---|---|
| hono | ~4.9.9 | HTTPフレームワーク |
| drizzle-orm | ~0.44.5 | ORM |
作成日: 2025-12-31 Flops Project - Technical Reuse Guide