アーキテクチャ
アーキテクチャ図(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/schedules: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不健全遷移・操作失敗を前倒しトリガーとする。 不健全検知時は、非入力中なら即時リロードして認証画面への遷移を促し、 入力中ならバナーで案内し回復検知時に自動解除する - 接続判定と検出トリガーの正本は
app/src/lib/connectivity-check.jsである。アプリ本体のconnection-recovery.svelte.tsとService Workerが返すオフライン画面 (app/src/routes/offline.html/+server.ts)が同じモジュールを使う。オフライン画面は、 オフライン時に追加のスクリプトを取得できずキャッシュ対象を増やせないため、Viteの?rawで 共有モジュールをインライン展開する
タイマー時刻同期
タイマーの残り時間をブラウザで正確に計算するため、サーバーとの時刻差(オフセット)を管理する。
オフセット計算
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", ...)で 最終受信時刻を更新できない。
定期TODOスケジューラー
schedulesテーブルのRRULE(app/src/lib/server/schema.ts)を起点に、 サーバー内setIntervalが定期的にTODOタスクを自動生成する。実装はapp/src/lib/server/scheduler.ts。
sequenceDiagram
participant K as SvelteKit init
participant T as setInterval (60秒)
participant D as DB
K->>D: 起動時フィルフォワード(未発火分の遡り生成)
loop 60秒ごと
T->>D: enabledなscheduleを走査し発火判定
T->>D: 発火予定ごとにtasks/schedulesを更新
end- 起動時と定期ポーリングの双方でfill-forward方式を採用する。 各スケジュールの
last_fired(未設定時はcreated)を起点に、現在時刻までの未発火分を遡って生成する - fill-forwardは
rruleのiterator経路で発火予定を1件ずつ検出し、 上限30件に達した時点で走査を打ち切る。 長期未発火の日次スケジュールでも全発火予定を配列化せずメモリ使用量を抑える - 上限30件を超える発火予定は31件目以降を生成せずスキップする。 この方式は上限の範囲内で重複発火を許容する設計であり、 上限を超えるフィルフォワードでは一部の発火予定が生成されずに終わる場合がある
- 全発火予定の生成後に1回、
rule.before(now, true)で求めた直近の発火予定を対象スケジュールのlast_firedへ設定し、次回ポーリングでの再検出(二重発火)を防ぐ - 発火機構は単一プロセスを前提とする。 現行のDocker Compose構成はアプリケーションサーバーが単一プロセスであるため、 複数プロセスワーカー間の発火競合は考慮しない。 複数プロセス構成へ拡張する場合、同一スケジュールへの並行発火は
list.user_idとlast_firedの更新競合により上記の重複許容範囲に収まる - スケジュールの
rrule列はDTSTART;TZID=Asia/Tokyo形式でタイムゾーンを保持する。rruleのbetween()が返すDateはAsia/Tokyoのローカル時刻をUTCとしてマークした値であるため、 市民時刻の時・分の取り出しにはgetHours()ではなくgetUTCHours()等のUTC系アクセサを使う
認証設計
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 を参照。以下はコードから読み取りにくい設計判断:
task.statusはactive・running・completed・archivedの4値を取り得る。runningは未完了としてTODOバッジへ計上し、lists.clear(完了済みタスクの非表示)の対象外とする日時カラムはすべてTIMESTAMP型でUTC保存。タイムゾーン変換はクライアント側で行い、 サーバー・DB層ではタイムゾーンを意識しない設計。 例外として、定期TODOスケジュール(
schedules.rrule)はRRULE文字列自体にDTSTART;TZID=Asia/Tokyo形式でタイムゾーンを持たせるrruleライブラリの タイムゾーン扱いを採用する並び順は
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系カラムを追加する場合は同基準で再点検する。