Skip to content

Instantly share code, notes, and snippets.

@umeyuki
Created December 31, 2025 05:23
Show Gist options
  • Select an option

  • Save umeyuki/57c5ef44ae232d9e890dd907758bd577 to your computer and use it in GitHub Desktop.

Select an option

Save umeyuki/57c5ef44ae232d9e890dd907758bd577 to your computer and use it in GitHub Desktop.
Flops 技術再利用ガイド - Parser & OGP画像生成の設計・実装ドキュメント

Flops 技術再利用ガイド

他プロダクトでの応用を目的とした設計・実装ドキュメント

概要

本ドキュメントは、Flopsプロジェクトで実装した以下の2つの主要機能について、設計思想、利用ライブラリ、失敗談、ベストプラクティスをまとめたものです:

  1. 独自記法のParser&再生機能 - テキストベースの記法をパースしてインタラクティブに描画・再生
  2. OGP画像生成機能 - 動的にOpen Graph画像を生成してSNS共有を最適化

第1章: 独自記法Parser&再生システム

1.1 設計思想

Single Source of Truth (SSOT)

テキスト入力が正規データ源という設計原則を採用しています。

ユーザー入力テキスト → Parser → 内部状態 → UI表示
                ↑                         ↓
                └─────── テキスト生成 ←─────┘

利点:

  • 状態の整合性保証(UIからの直接変更を禁止)
  • デバッグが容易(テキストを見れば状態がわかる)
  • 共有・保存が簡単(テキストをそのまま保存)
  • バージョン管理と差分表示が可能

Immutable State Transitions

すべての状態遷移は新しいオブジェクトを生成します。既存状態を変更しません。

// ❌ 悪い例:直接変更
state.players.push(newPlayer);
state.pot += betAmount;

// ✅ 良い例:新しいオブジェクト生成
const newState = {
  ...state,
  players: [...state.players, newPlayer],
  pot: state.pot + betAmount,
};

利点:

  • Undo/Redoの実装が容易(状態のスナップショット保持)
  • バグの原因特定が容易(状態変更の追跡可能)
  • Reactivity(Svelte/React等)との相性が良い

1.2 アーキテクチャ

パッケージ構成

packages/
├── domain/     # 型定義・スキーマ(依存なし)
├── engine/     # ビジネスロジック(domain依存)
├── parser/     # テキスト解析(domain, engine依存)
└── utils/      # 共有ユーティリティ

apps/
├── frontend/   # SvelteKit UI
└── backend/    # Hono API

依存関係の方向: domain ← engine ← parser ← frontend

循環依存を防ぐため、下位パッケージは上位パッケージを参照しません。

Parserパイプライン

入力テキスト
    ↓
┌─────────────────┐
│   Lexer         │ トークン分割
└─────────────────┘
    ↓
┌─────────────────┐
│ Pattern Matchers│ 優先度付きマッチング
└─────────────────┘
    ↓
┌─────────────────┐
│ GameStateBuilder│ 状態オブジェクト構築
└─────────────────┘
    ↓
┌─────────────────┐
│ValidationPipeline│ セマンティック検証
└─────────────────┘
    ↓
  GameState

1.3 実装パターン

Pattern Matcher(優先度付きマッチング)

// 各マッチャーは優先度順に試行される
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で残りを返す

GameStateBuilder(インクリメンタル更新)

// リアルタイム編集対応のため、行単位で状態更新可能
export function updateGameStateFromParsedLine(
  currentState: GameState,
  parsedLine: ParsedLineResult,
  lineNumber: number
): GameState {
  // 1. 既存行の検証
  // 2. 差分計算
  // 3. 新しいGameState生成
  return { ...currentState, /* 更新内容 */ };
}

1.4 利用ライブラリ

ライブラリ 用途 選定理由
Zod スキーマ定義・バリデーション TypeScript統合、エラーメッセージのカスタマイズ性
Svelte Store 状態管理(フロントエンド) Svelteとの相性、シンプルなAPI

1.5 失敗談と教訓

失敗1: 型定義の重複

問題: 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パッケージに型を一元化
  • 変換アダプター層を排除し、単一の型を全レイヤーで共有

失敗2: クラスベース実装の混在

問題: 一部のServiceがクラスで実装され、関数型コードと混在

教訓:

  • 全ロジックを純粋関数で実装
  • 依存性はカリー化またはパラメータ渡しで解決
// ✅ 関数型アプローチ
const createAuthService = (db: Database) => ({
  login: async (email: string) => { ... },
});

失敗3: Step再生でのアクション表示スキップ

問題: 最終アクションと完了フラグを同じステップに設定したため、最終アクションが表示されずにショーダウンに遷移

教訓:

  • アクションステップと完了ステップは分離する
  • 状態遷移の単位を明確に定義
// ✅ 修正後
// アクションステップ(全アクション)
const actionSteps = actions.map(a => ({ type: 'action', action: a }));
// 完了ステップ(最後に追加)
if (isComplete) {
  actionSteps.push({ type: 'completion', outcome });
}

失敗4: 2ウェイデータバインディングの過度な使用

問題: UI入力とパース結果の双方向バインディングで状態の整合性が崩れた

教訓:

  • 単方向データフローを徹底
  • UIはテキスト入力のみを受け付け、状態はParserからのみ更新

1.6 再利用のためのチェックリスト

  • ドメイン型を独立パッケージに分離
  • Parser → State → UI の単方向フロー設計
  • Immutable更新パターンの採用
  • Pattern Matcherの優先度定義
  • インクリメンタル更新対応(リアルタイム編集向け)
  • 検証エラーの蓄積と詳細メッセージ

第2章: OGP画像生成システム

2.1 設計思想

Cloudflare Workers対応

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;
}

2.2 技術スタック

データ抽出 → 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 フォントサブセット化(ビルド時)

2.3 実装パターン

APIエンドポイント設計

// 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

HTMLテンプレート設計

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');

2.4 失敗談と教訓

失敗1: Node.js依存ライブラリの選択

問題: 最初にSharpを選択したが、Cloudflare Workersで動作しない

教訓:

  • Edge/Workers環境を意識したライブラリ選定
  • WASM版があるかを事前確認
  • resvg-wasmは純Rust→WASMで環境非依存

失敗2: WASM同時初期化エラー

問題: 複数リクエストが同時にWASM初期化を試み、「Already initialized」エラー

教訓:

  • シングルトンPromiseパターンで同時初期化を防止
  • 「Already initialized」エラーは無視して続行
// ✅ 競合防止
if (wasmInitPromise) {
  await wasmInitPromise; // 他のリクエストを待機
  return;
}
wasmInitPromise = initializeWasm();

失敗3: フォントパス解決の問題

問題: 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());
}

失敗4: Canvas専用レンダラーによるGIF出力の試み

問題: GIF/PNG export用にCanvas専用レンダラーを実装したが、レイアウトの動的複雑性により二重管理が非現実的に

Issue #19 調査結果:
✅ 技術的成功: Canvas描画、PNG生成は動作
❌ 課題: レイアウトの動的複雑性(straddle、responsive、動的プレイヤー数)
❌ 保守コスト: PokerTable.svelteとCanvas版の二重管理が非現実的

教訓:

  • 既存UIコンポーネントを画像化するアプローチを優先
  • Satori + resvg-wasmならHTMLテンプレートをそのまま画像化可能
  • Canvas専用レンダラーは「最終手段」として検討

2.5 パフォーマンス最適化

最適化項目 効果
フォントサブセット化 4.44MB → 50.71KB (98.9%削減)
WASMシングルトン 再初期化回避
キャッシュヘッダー CDN活用(1年キャッシュ)
タイムアウト保護 無限待機防止
プリフロップfoldフィルタ 視覚的ノイズ削減

2.6 再利用のためのチェックリスト

  • Edge/Workers環境対応ライブラリの選定(WASM版確認)
  • WASM初期化のシングルトンパターン実装
  • フォントサブセット化(必要文字のみ抽出)
  • タイムアウト管理の実装
  • キャッシュ戦略の設計(immutableヘッダー)
  • HTMLテンプレートはFlexbox + インラインスタイルのみ

第3章: 共通の学び

3.1 モノレポ構成のベストプラクティス

# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'

# バージョン管理は catalog で一元化
catalog:
  typescript: ^5.0.0
  svelte: ^5.0.0
  zod: 4.1.5

利点:

  • 依存バージョンの一元管理
  • パッケージ間の型共有が容易
  • ビルド順序の自動解決(pnpm + tsup)

3.2 関数型プログラミング原則

  1. クラス禁止 - 全ロジックを純粋関数で実装
  2. Immutable更新 - スプレッド演算子による新規オブジェクト生成
  3. Result型 - 例外の代わりに{ success, data, error }パターン
  4. Pipe関数 - 処理の合成と可読性向上

3.3 テスト戦略

// 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');
  });
});

3.4 デプロイメント構成

Frontend: Cloudflare Pages
├── SvelteKit + adapter-cloudflare
└── OGP画像生成(サーバーサイド)

Backend: Cloudflare Workers
├── Hono (軽量HTTPフレームワーク)
├── D1 (SQLiteベースDB)
└── Drizzle ORM (型安全)

付録A: 利用ライブラリ一覧

Parser関連

ライブラリ バージョン 用途
zod 4.1.5 スキーマ定義・バリデーション
typescript ^5.0.0 型システム

OGP関連

ライブラリ バージョン 用途
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment