Skip to content

アーキテクチャ

アーキテクチャ図(Docker Compose 内部構成)

mermaid
flowchart TB
    Browser["Chrome拡張 / ブラウザ"]
    Nginx["nginx(ポート 38180:443)"]
    SK["SvelteKit サーバー"]
    DB["MariaDB"]

    Browser -- "HTTPS + AES-GCM 難読化" --> Nginx
    Nginx -- "全リクエスト" --> SK
    SK -- "Drizzle ORM" --> DB

外部リバースプロキシ設定

Docker Composeの前段にLet's Encrypt証明書でHTTPS終端する外部nginxを配置する場合の設定要点。

mermaid
flowchart LR
    Browser["ブラウザ"]
    ExtNginx["外部 nginx\n(Let's Encrypt)"]
    IntNginx["内部 nginx\n(自己署名 HTTPS, :38180)"]
    SK["SvelteKit"]

    Browser -- "HTTPS" --> ExtNginx
    ExtNginx -- "HTTPS(proxy_ssl_*)" --> IntNginx
    IntNginx --> SK

設定上の制約:

  • 内部nginxは自己署名HTTPSのため、/api/events/の両方に proxy_ssl_certificate / proxy_ssl_certificate_keyを指定する
  • SSE用の/api/eventsにはproxy_buffering offに加えproxy_read_timeout 86400proxy_http_version 1.1Connection ''を指定する。 さらにX-Accel-Buffering: noヘッダーでレスポンスバッファも無効化する (SSEがバッファされると配信遅延が発生するため)

最小サンプル(要点のみ):

nginx
location /api/events {
    proxy_pass https://app_backend;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_ssl_certificate     /path/to/glatasks/web/ssl/server.crt;
    proxy_ssl_certificate_key /path/to/glatasks/web/ssl/server.key;
    proxy_buffering off;
    proxy_read_timeout 86400;
    add_header X-Accel-Buffering no;
}

location / {
    proxy_pass https://app_backend;
    proxy_ssl_certificate     /path/to/glatasks/web/ssl/server.crt;
    proxy_ssl_certificate_key /path/to/glatasks/web/ssl/server.key;
}

Host / X-Forwarded-For / X-Forwarded-Proto等の標準ヘッダーは通常通り設定する。

リアルタイム同期(SSE)

mutation完了時にSSE (Server-Sent Events) で他タブ・他端末へ即座に通知する。

mermaid
sequenceDiagram
    participant A as ブラウザ A
    participant S as SvelteKit
    participant B as ブラウザ B

    A->>S: tRPC mutation(タスク更新)
    S->>S: DB 更新
    S-->>A: SSE: tasks:updated
    S-->>B: SSE: tasks:updated
    A->>S: invalidateQueries → 再取得
    B->>S: invalidateQueries → 再取得
  • エンドポイント: GET /api/events(Cookie認証)
  • データは含めずイベント種別のみ送信 (lists:updated / tasks:updated / timers:updated / users:preferences:updated / reset
  • クライアントはイベント受信時にTanStack Queryの invalidateQueries で該当データを再取得
  • 接続の健全性はクライアント側で監視する。EventSourceの自動再接続に加え、 受信ウォッチドッグ(30秒周期で判定、最終受信から75秒経過で強制再接続)・ errorイベント発火時のreadyState=CLOSED即時再接続・ visibilitychangeでの可視復帰時の前倒し判定を組み合わせ、 ブラウザのネットワークタイマー減速等で接続がゾンビ化した際の差分同期取りこぼしを防ぐ
  • nginx: /api/eventsproxy_buffering off を設定(SSEがバッファされると配信遅延が発生するため)
  • 検出と同期の役割を分離する。同期はSSEイベント受信時の再取得が担い、 SSE応答が長期間届かない事態に備えてポーリングフォールバック機構を併設する。 初回接続後10秒または受信途絶60秒で不健全と判定し、SSE経由で再取得対象となる全クエリーへ 即時1回のフォールバック実行と30秒間隔の継続ポーリングを起動する。 SSE側で何らかのイベントを受信した時点で健全へ復帰しポーリングを停止する。 フォールバックはデータ再取得に専念し、リロード誘導は行わない。 SSE接続自体はポーリング期間中も維持する
  • 検出はSSEとは独立した能動検出経路が担う。/healthcheckへ能動的にチェックし、 HTTPステータス200・application/json・本文のstatus値がokの3点が揃った場合のみ健全と判断する。 キャプティブポータルはステータス200で認証用HTMLを返すことがあるため、 ステータスだけで判別せずContent-Typeと本文の値まで検査する。 定期ポーリング(30秒間隔・非表示タブでは停止)に加え、可視復帰・オンライン復帰・ SSE不健全遷移・操作失敗を前倒しトリガーとする。 不健全検知時は、非入力中なら即時リロードして認証画面への遷移を促し、 入力中ならバナーで案内し回復検知時に自動解除する

タイマー時刻同期

タイマーの残り時間をブラウザで正確に計算するため、サーバーとの時刻差(オフセット)を管理する。

オフセット計算

tRPCレスポンスでRTT/2補正付きの精密なオフセットを計算する:

text
offset = serverTime - (requestStart + requestEnd) / 2

オフセット更新の流れ

text
SSE connected (初回接続)  ──→ setServerOffset(暫定値)
tRPC response (1分間隔)   ──→ setServerOffset(RTT/2補正値)
tRPC response (SSEイベント後) → setServerOffset(RTT/2補正値)
SSE heartbeat (30秒ごと)  ──→ 接続維持のみ(オフセット更新なし)

SSEは片道通信のためRTTを測定できない。heartbeatのサーバー時刻でオフセットを上書きすると、 tRPCのRTT/2補正で得た精密値が片道遅延分だけズレた値に劣化するため、heartbeatは接続維持のみに使用する。

heartbeatの送出形式には名前付きイベント(event: heartbeat)を採用する。 コメント行形式(:\n\n)ではEventSourceがイベントとして通知せず、 クライアント側の受信ウォッチドッグがaddEventListener("heartbeat", ...)で 最終受信時刻を更新できない。

認証設計

CookieベースのJWT/HS256セッション。実装詳細は hooks.server.ts / session.ts を参照。

CSRF 対策

多層防御を採用している。

  • SvelteKit組み込み機能でform actionを保護
  • カスタムリクエストヘッダーのオリジンチェックでtRPCエンドポイント(/api/*)のクロスサイトミューテーションをブロック
  • ログアウトをPOSTに限定(GETリクエストによるCSRFを防止するため)

Chrome拡張のポップアップ内iframeからのアクセスを許可する必要があるため、 Cookieに sameSite: "none" + secure: true を設定している。 この設定はCSRF対策を弱めるため、上記のオリジンチェックで補完している。

tRPCミドルウェア設計

tRPC v11のミドルウェア(t.middleware)内で下流のprocedureが投げた例外を捕捉するには、 try/catch ではなく await next() の戻り値の result.ok を判定する。 tRPC v11の next() は例外を再送出せず {ok: false, error} で返す仕様のため、 try/catch 側は到達しない。

実例は app/src/lib/server/trpc.tswithApiErrors にある。 同ミドルウェアは機械可読な識別子をUI文言へ変換する。 要点を簡略化すると次の形になる。

ts
const withApiErrors = t.middleware(async ({ next }) => {
  const result = await next();
  if (!result.ok) {
    // result.error を検査してマッピングする
  }
  return result;
});

withEncryption も同じ result.ok パターンを使う。 新しいミドルウェアを追加・改修する際は同じ判定形式に揃える。

DB 設計方針

テーブル定義は app/src/lib/server/schema.ts を参照。以下はコードから読み取りにくい設計判断:

  • 日時カラムはすべてTIMESTAMP型でUTC保存。タイムゾーン変換はクライアント側で行い、 サーバー・DB層ではタイムゾーンを意識しない設計
  • 並び順は sort_order INTカラム(昇順、1000刻み)。刻み幅を大きくすることで、挿入時に周囲のレコードを更新せずに済む。 ドメインごとの挿入位置: タスク(tasks.create)は sort_order = 既存最小値 - 1000 で先頭挿入、 タイマー(timers.create)は sort_order = 既存最大値 + 1000 で末尾追加
  • タイマーの残り時間はサーバー側で計算せず、remaining_secondsstarted_at をクライアントに渡して ブラウザ側で計算する。これにより秒単位のリアルタイム表示をポーリングなしで実現する
  • タイマーの ephemeral カラムは1回限り使用するタイマーを識別するフラグ。 作成時のみ設定でき、adjust / reset / setTime などの操作で変更されない。 クライアント側では満了到達時に削除ボタンを強調し、確認ダイアログを省略して削除できる体験に用いる

バイナリ保存と max_allowed_packet

mediumbloblongblob カラムへバイナリを保存する場合、 drizzle-orm mysql2ドライバーの db.transaction は 内部で client.query()(テキストプロトコル)を採用する。 Buffer パラメーターは16進数リテラルへ変換されて送信されるため、 実バイト数の約2倍のSQLテキストがサーバーへ流れる。 db/my.cnfmax_allowed_packet は元の想定バイナリサイズの3倍以上に設定する (現行値は 64M)。添付ファイル上限が10 MiBのため、 テキスト膨張とプロトコルオーバーヘッドを吸収する余裕を確保している。 新しいBLOB系カラムを追加する場合は同基準で再点検する。