アーキテクチャ
アーキテクチャ図(Docker Compose 内部構成)
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を配置する場合の設定要点。
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 86400・proxy_http_version 1.1・Connection ''を指定する。 さらにX-Accel-Buffering: noヘッダーでレスポンスバッファも無効化する (SSEがバッファされると配信遅延が発生するため)
最小サンプル(要点のみ):
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) で他タブ・他端末へ即座に通知する。
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/eventsにproxy_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補正付きの精密なオフセットを計算する:
offset = serverTime - (requestStart + requestEnd) / 2オフセット更新の流れ
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.ts の withApiErrors にある。 同ミドルウェアは機械可読な識別子をUI文言へ変換する。 要点を簡略化すると次の形になる。
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_orderINTカラム(昇順、1000刻み)。刻み幅を大きくすることで、挿入時に周囲のレコードを更新せずに済む。 ドメインごとの挿入位置: タスク(tasks.create)はsort_order = 既存最小値 - 1000で先頭挿入、 タイマー(timers.create)はsort_order = 既存最大値 + 1000で末尾追加 - タイマーの残り時間はサーバー側で計算せず、
remaining_secondsとstarted_atをクライアントに渡して ブラウザ側で計算する。これにより秒単位のリアルタイム表示をポーリングなしで実現する - タイマーの
ephemeralカラムは1回限り使用するタイマーを識別するフラグ。 作成時のみ設定でき、adjust/reset/setTimeなどの操作で変更されない。 クライアント側では満了到達時に削除ボタンを強調し、確認ダイアログを省略して削除できる体験に用いる
バイナリ保存と max_allowed_packet
mediumblob・longblob カラムへバイナリを保存する場合、 drizzle-orm mysql2ドライバーの db.transaction は 内部で client.query()(テキストプロトコル)を採用する。 Buffer パラメーターは16進数リテラルへ変換されて送信されるため、 実バイト数の約2倍のSQLテキストがサーバーへ流れる。 db/my.cnf の max_allowed_packet は元の想定バイナリサイズの3倍以上に設定する (現行値は 64M)。添付ファイル上限が10 MiBのため、 テキスト膨張とプロトコルオーバーヘッドを吸収する余裕を確保している。 新しいBLOB系カラムを追加する場合は同基準で再点検する。