17 KiB
server.js リファクタリング計画
最終更新: 2026-04-10 対象: Claude Code / Cursor / Gemini / 全 AI エージェント
このドキュメントは server.js の現状分析・分割設計・実施タイミング判断基準を一元管理します。
リファクタリング作業を始める前に必ず読んでください。
0. 現状サマリー
| 項目 | 内容 |
|---|---|
| ファイル | /server.js(リポジトリルート) |
| 行数 | 約3130行(2026-04-10 計測・セキュリティ修正追加後) |
| 役割 | VPS 上で動く Node.js/Express バックエンド。全アプリの API を1ファイルに集約 |
| 問題 | テストが書けない・障害切り分けが困難・新機能追加のたびにファイルが肥大化 |
| 状態 | 本番稼働中・障害なし。2026-04-10 にセキュリティ修正7件適用済み。 |
| セキュリティ | SSRF ガード追加・レスポンスサイズ上限追加・DB pool 設定改善済み |
1. 現在の内部構造(セクション別行数)
server.js (約3130行)
├── L1–27 require / app 初期化
├── L28–84 Auth helpers(WebAuthn dynamic import、インメモリレートリミッター)
├── L85–126 JWT config + session helpers
├── L127–173 Express middleware(CORS など)
├── L174–300 DB pool (pg) + Gemini + API Key 認証マップ
│ ※ pool.max 15・タイムアウト設定・pool.on('error') 追加済み
├── L261–520 共有ユーティリティ(extractSource、charset 正規化、
│ isSsrfSafe(NEW)、fetchMeta(SSRF+2MB上限追加)、
│ fetchFullTextViaJina(SSRF+1MB上限追加)、analyzeWithGemini)
├── L521–760 initDB(PostgreSQL スキーマ全定義 — 240行)
├── L761–809 Express Router 生成 + /health
│
├── ── ルートグループ ─────────────────────────────────────────────
├── L810–1146 Auth: Magic Link + session(337行)
├── L966–1146 Auth: Google OAuth / GitHub OAuth(181行)※上記と連続
├── L1147–1394 Auth: Passkey / WebAuthn(248行)
│ 合計 Auth routes: 585行
│
├── L1395–1680 Brain articles(GET/POST/PATCH/DELETE + save(即時保存+非同期AI)+ history)
│ ※ POST /save: 即時INSERT → setImmediate() で fetchMeta/AI をバックグラウンド実行
├── L1681–1760 Journal routes(posts + public + AI tag suggestion + upload)
├── L1761–1860 Site Config routes
├── L1861–1960 Habit routes
├── L1961–2010 Pulse routes
├── L2011–2050 Lens history routes
│
├── L2051–2210 Feed Media + Feed Articles routes
├── L2211–2390 TTS(VOICEVOX)routes + サーバー側自動プリウォーム
│
├── L2391–2430 Events前処理
├── L2431–2900 Together routes(groups / join / share / feed / comments / search)
│ ※ POST /together/share: isSsrfSafe() 適用済み
│ ※ archiveShare(): isSsrfSafe() + 1MB上限適用済み
├── L2740–2900 Atlas proxy(GitHub / Vercel / Tailscale scan)※上記と連続
│
├── L2901–3010 Doorkeeper + connpass(app-level、router 外)
├── L3011–3015 静的ファイル(/uploads)
├── L3017–3115 Stripe webhook
│
└── L3116–3130 router マウント + サーバー起動 + Feed 背景ジョブ
2. 共有変数の依存マップ
分割時に「どの変数をどのファイルに置くか」の判断基準。
| 変数 / 関数 | 宣言行 | 参照箇所数 | 参照元セクション |
|---|---|---|---|
pool (pg Pool) |
L174 | 108箇所 | 全ルートファイル |
authMiddleware |
L197付近 | 46箇所 | 全認証必須ルート |
genAI (Gemini) |
L191 | 2箇所 | brain.js のみ |
webauthnChallenges (Map) |
L40 | 15箇所 | auth routes のみ |
rateLimitStores / rateLimit() |
L51 | 多数 | auth routes 中心 |
isSsrfSafe() |
L294付近 | 7箇所 | brain.js / together.js |
fetchMeta() |
L304付近 | brain + together | |
fetchFullTextViaJina() |
L372付近 | brain + together | |
analyzeWithGemini() |
L416付近 | brain のみ | |
JWT_SECRET |
L86 | auth 全体 | |
WEBAUTHN_RP_* |
L92 | auth/passkey のみ |
結論: pool と authMiddleware を共有モジュールに切り出せば、残りのルート分割は機械的に行える。
3. 目標とする分割後の構造
server.js ← 100行以内(エントリポイント + マウントのみ)
lib/
db.js ← pool + initDB をエクスポート
auth.js ← JWT_SECRET / createToken / authMiddleware / session helpers
rateLimit.js ← インメモリレートリミッター(webauthnChallenges も含める)
fetch.js ← isSsrfSafe / fetchMeta / fetchFullTextViaJina / analyzeWithGemini
routes/
auth.js ← Magic Link + OAuth(Google/GitHub)+ Passkey/WebAuthn
brain.js ← articles / save / history / AI 分析
journal.js ← journal posts + site config
habit.js ← habit + pulse + lens history
feed.js ← feed media + feed articles + TTS + feed バックグラウンドジョブ
together.js ← groups / join / share / comments / search
events.js ← Doorkeeper + connpass(app-level も含めて統合)
atlas.js ← GitHub / Vercel / Tailscale scan proxy
stripe.js ← webhook のみ
各ファイルの想定行数: 100–600行。神ファイルは完全に消える。
4. 実施タイミングの判断基準
やらなくていい条件(すべて揃っている間は待つ)
- 本番で分割に起因する障害が出ていない
- 新しいルートグループを追加する予定がない
- 商用化タスク(Stripe 課金・ユーザー招待・モバイル)が残っている
開始するべきタイミング(どれか1つ該当したら着手)
| トリガー | 理由 |
|---|---|
| 新ルートグループを追加するとき(例: posimai-station の API) | 新ファイルを作るついでに既存も整理。追加コストがほぼゼロ |
| auth.js だけバグが続いて server.js 内のデバッグが辛い | 問題のある1ルートだけ先に切り出す部分分割でも OK |
| Node.js バージョンアップや pg ライブラリ更新のとき | どうせ動作確認が必要なので、そのタイミングで進める |
| server.js が 3500行を超えたとき | 純粋に限界。この時点で強制着手 |
5. 実施手順(着手するときはこの順番で)
フェーズ 1: 共有レイヤーの切り出し(低リスク・1–2時間)
ルートは一切触らない。共有変数をモジュールに切り出すだけ。
mkdir lib routes
lib/db.js
'use strict';
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.DB_HOST || 'db',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'gitea', // TODO: 'posimai' に変更
password: process.env.DB_PASSWORD || '',
database: 'posimai_brain',
max: 15,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
pool.on('error', (err) => console.error('[DB] Unexpected pool error:', err.message));
async function initDB() { /* server.js の initDB をそのまま移動 */ }
module.exports = { pool, initDB };
lib/auth.js
// JWT_SECRET, JWT_TTL_SECONDS, createToken, authMiddleware, session helpers
module.exports = { JWT_SECRET, createToken, authMiddleware };
lib/rateLimit.js
// rateLimitStores, rateLimit(), webauthnChallenges, cleanup interval
module.exports = { rateLimit, webauthnChallenges };
lib/fetch.js
// isSsrfSafe, fetchMeta, fetchFullTextViaJina, analyzeWithGemini, extractSource
// ※ isSsrfSafe は 2026-04-10 に server.js 内で実装済み。コピーして移動するだけ。
module.exports = { isSsrfSafe, fetchMeta, fetchFullTextViaJina, analyzeWithGemini };
server.js 側の変更は require 置き換えのみ:
const { pool, initDB } = require('./lib/db');
const { authMiddleware } = require('./lib/auth');
動作確認:
node server.js
curl http://localhost:PORT/api/health
フェーズ 2: ルートの切り出し(中リスク・1ルート1PR)
独立度が高い順(この順番で進める):
1. routes/stripe.js 99行 依存: pool のみ。最も安全
2. routes/atlas.js 151行 依存: なし(外部API プロキシのみ)
3. routes/events.js 141行 依存: なし(外部API フェッチのみ)
4. routes/together.js 455行 依存: pool + authMiddleware + isSsrfSafe
5. routes/feed.js 321行 依存: pool + authMiddleware(feed job は起動後アタッチ)
6. routes/habit.js 185行 依存: pool + authMiddleware
7. routes/brain.js 258行 依存: pool + authMiddleware + fetch + genAI
8. routes/journal.js 266行 依存: pool + authMiddleware
9. routes/auth.js 585行 依存: webauthnChallenges + rateLimit(フェーズ1完了後)
各ルートファイルの形式:
'use strict';
const { Router } = require('express');
const { pool } = require('../lib/db');
const { authMiddleware } = require('../lib/auth');
// ...
const router = Router();
router.get('/xxx', authMiddleware, async (req, res) => { ... });
module.exports = router;
server.js でのマウント:
const stripeRouter = require('./routes/stripe');
app.use('/brain/api/stripe', stripeRouter);
app.use('/api/stripe', stripeRouter);
フェーズ 2 の注意点(ハマりやすい箇所)
-
feed バックグラウンドジョブは routes/feed.js に移すが、
startFeedJob()の呼び出しは server.js の起動後に行う必要がある。module.exports = { router, startFeedJob }の形式にする。 -
Doorkeeper / connpass の二重定義(router 外の app-level)は events.js に統合し、router 内に移動する。
/brain/api/events/*と/api/events/*の両マウントを忘れずに。 -
auth routes の webauthnChallenges は auth.js ルートファイルと lib/rateLimit.js(またはlib/auth.js)が共有するため、フェーズ1の lib 切り出し完了後でないと移動できない。必ずフェーズ1の後に行う。
-
POST /save の setImmediate() バックグラウンド処理(2026-04-10 追加): brain.js に移動する際、
setImmediate()コールバック内のpool.queryは routes/brain.js スコープでpoolにアクセスできる必要がある。lib/db.js から require する形にすれば問題ない。
6. 2026-04-10 適用済みセキュリティ・信頼性修正
リファクタリング前に既に対処済みの問題。分割作業時はこの修正が維持されていることを確認すること。
| # | 修正内容 | 箇所 | リスク(修正前) |
|---|---|---|---|
| 1 | DB pool max: 5 → 15 + idleTimeoutMillis: 30000 + connectionTimeoutMillis: 5000 追加 |
pool 初期化 |
setImmediate() 並列実行時に pool 枯渇 |
| 2 | pool.on('error', ...) ハンドラ追加 |
pool 初期化直後 |
未捕捉エラーでプロセスクラッシュ |
| 3 | isSsrfSafe() 関数追加 |
共有ユーティリティ冒頭 | ユーザー入力 URL を VPS から fetch → イントラネット攻撃 |
| 4 | fetchMeta() に SSRF ガード + 2MB上限 |
fetchMeta 関数 | 内部 IP への GET・無制限レスポンスでメモリ枯渇 |
| 5 | fetchFullTextViaJina() に SSRF ガード + 1MB上限 |
fetchFullTextViaJina 関数 | Jina 経由の SSRF・巨大レスポンスでメモリ枯渇 |
| 6 | POST /together/share に isSsrfSafe() 適用 |
Together routes | Together 投稿 URL 経由の SSRF |
| 7 | archiveShare() に isSsrfSafe() + 1MB上限 |
archiveShare 関数 | アーカイブ処理での SSRF・サイズ無制限 |
isSsrfSafe() の実装(lib/fetch.js に移動予定)
const SSRF_BLOCKED = /^(127\.|localhost$|::1$|0\.0\.0\.0$|169\.254\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|100\.100\.100\.100|metadata\.google\.internal)/i;
function isSsrfSafe(rawUrl) {
let parsed;
try { parsed = new URL(rawUrl); } catch { return false; }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
if (SSRF_BLOCKED.test(parsed.hostname)) return false;
return true;
}
7. POST /save の非同期処理パターン(2026-04-10 実装)
記事保存の即時性改善のため、POST /save と GET /quick-save を以下のパターンに変更済み。 分割時にこのパターンを維持すること。
// 1. 即時 INSERT(domain を仮タイトルとして使用)
await pool.query(`INSERT INTO articles (...) VALUES (...)`, [userId, url, domain, null, null, ...]);
res.json({ ok: true, article: { id, title: domain, ... } });
// 2. バックグラウンドで fetchMeta → DB UPDATE → Jina → DB UPDATE → Gemini → DB UPDATE
setImmediate(async () => {
try {
const meta = await fetchMeta(url);
await pool.query(`UPDATE articles SET title=$1, favicon=$2 WHERE id=$3`, [meta.title, meta.favicon, articleId]);
const fullText = await fetchFullTextViaJina(url);
await pool.query(`UPDATE articles SET full_text=$1 WHERE id=$2`, [fullText, articleId]);
const summary = await analyzeWithGemini(fullText || meta.description || '');
await pool.query(`UPDATE articles SET summary=$1 WHERE id=$2`, [summary, articleId]);
} catch (e) {
console.error('[save/background]', e.message);
}
});
8. やらないこと(スコープ外)
| 項目 | 理由 |
|---|---|
| TypeScript 化 | 全書き直しになる。現在の優先度では対費用効果が出ない |
| テストコード追加 | 分割と同時にやると範囲が広がりすぎる。分割後の別タスク |
| DB_USER: 'gitea' の修正 | サーバー側 PostgreSQL ユーザー変更が伴う。別タスク |
| console.log 整理 | 分割と無関係。別タスク |
| インメモリ state の永続化(Redis 等) | webauthnChallenges / rateLimitStores をDB/Redisに移行。スキーマ変更が必要・mai 確認必須 |
9. 検討中の追加改善(分割後の次フェーズ候補)
- DB_USER を 'gitea' から 'posimai' に変更(PostgreSQL ユーザー作成が必要)
- webauthnChallenges / rateLimitStores の Redis 移行(再起動耐性・スケール対応)
- console.log を構造化ログ(pino 等)に統一
- ルートごとのエラーハンドリング統一(現状は各ルートで個別 try/catch)
isSsrfSafe()の DNS 解決後チェック追加(DNS rebinding 対策。現状はホスト名の正規表現のみ)
10. 次ステップ(優先順)
商用化とリファクタリングは並列トラックで管理する。
トラック A: 商用化(最優先)
| ステップ | 担当 | 前提 |
|---|---|---|
| どのアプリに premium 機能を実装するか決める | mai 決定必要 | — |
| 特商法ページ記入(事業者名・住所・電話番号) | mai 手動作業 | — |
| Store デザイン確定(A/B/C/D) | Eiji + mai | — |
各アプリへの purchased フラグ実装 |
AI 対応可 | mai の対象決定後 |
| Stripe 本番モード切り替え | AI + mai 確認 | Store 確定・特商法完了後 |
トラック B: リファクタリング(タイミング待ち)
セクション 4 の「開始するべきタイミング」に該当するまで待つ。 現時点で最も近いトリガーは「server.js が 3500行を超えたとき」(現在 約3130行)。
新機能追加(posimai-station API 等)が発生した場合は、そのタイミングでフェーズ1から着手する。
このドキュメントは作業開始時・完了時に更新すること。 分割を実施した AI は「フェーズ X 完了」「完了日」「担当 AI」を末尾に追記してください。
完了履歴
| フェーズ | 完了日 | 担当 AI | 内容 |
|---|---|---|---|
| セキュリティ修正(フェーズ0相当) | 2026-04-10 | Claude Sonnet 4.6 | SSRF ガード・サイズ上限・pool 設定改善・即時保存パターン(全7件) |