posimai-root/posimai-dev/server.js

167 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const express = require('express');
const { WebSocketServer } = require('ws');
const pty = require('node-pty');
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync } = require('child_process');
const app = express();
const PORT = process.env.PORT || 3333;
// セッションログ保存ディレクトリ
const SESSIONS_DIR = path.join(os.homedir(), 'posimai-dev-sessions');
if (!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true });
app.use(express.json());
app.use(express.static(path.join(__dirname)));
// セッション一覧 API
app.get('/api/sessions', (req, res) => {
const files = fs.readdirSync(SESSIONS_DIR)
.filter((f) => f.endsWith('.log'))
.map((f) => {
const stat = fs.statSync(path.join(SESSIONS_DIR, f));
return { id: f.replace('.log', ''), size: stat.size, mtime: stat.mtime };
})
.sort((a, b) => new Date(b.mtime) - new Date(a.mtime));
res.json(files);
});
// セッション内容 API
app.get('/api/sessions/:id', (req, res) => {
const file = path.join(SESSIONS_DIR, req.params.id + '.log');
if (!fs.existsSync(file)) return res.status(404).json({ error: 'not found' });
res.type('text/plain').send(fs.readFileSync(file, 'utf8'));
});
// ── ヘルス & メトリクス API (/api/health) ──────────────────────
// Atlas など外部から参照される。CORS ヘッダーを付与して Vercel 上の Atlas からも取得可能にする
function getCpuSample() {
return os.cpus().map((c) => {
const total = Object.values(c.times).reduce((a, b) => a + b, 0);
return { total, idle: c.times.idle };
});
}
app.get('/api/health', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
const mem = os.freemem();
const total = os.totalmem();
// CPU: 100ms 間隔の2サンプルで実使用率を計算
const s1 = getCpuSample();
setTimeout(() => {
const s2 = getCpuSample();
const cpuPct = s1.reduce((sum, c1, i) => {
const c2 = s2[i];
const dIdle = c2.idle - c1.idle;
const dTotal = c2.total - c1.total;
return sum + (dTotal > 0 ? (1 - dIdle / dTotal) * 100 : 0);
}, 0) / s1.length;
// Disk usage
let disk = null;
try {
const dfOut = execSync('df -B1 / 2>/dev/null', { timeout: 2000 }).toString();
const parts = dfOut.trim().split('\n')[1].split(/\s+/);
const totalB = parseInt(parts[1]);
const usedB = parseInt(parts[2]);
disk = {
total_gb: Math.round(totalB / 1e9 * 10) / 10,
used_gb: Math.round(usedB / 1e9 * 10) / 10,
use_pct: Math.round(usedB / totalB * 100),
};
} catch (_) {}
// Load average (1 / 5 / 15 min) and CPU count
const loadAvg = os.loadavg();
const cpuCount = os.cpus().length;
res.json({
ok: true,
hostname: os.hostname(),
uptime_s: Math.floor(os.uptime()),
cpu_pct: Math.round(cpuPct),
cpu_count: cpuCount,
load_avg: loadAvg.map(l => Math.round(l * 100) / 100),
mem_used_mb: Math.round((total - mem) / 1024 / 1024),
mem_total_mb: Math.round(total / 1024 / 1024),
disk,
active_sessions: wss.clients ? wss.clients.size : 0,
node_version: process.version,
platform: os.platform(),
timestamp: new Date().toISOString(),
});
}, 100);
});
// Tailscale証明書を自動検出
function findCert() {
const home = os.homedir();
const crt = fs.readdirSync(home).find((f) => f.endsWith('.crt'));
if (!crt) return null;
const key = crt.replace('.crt', '.key');
if (!fs.existsSync(path.join(home, key))) return null;
return { cert: fs.readFileSync(path.join(home, crt)), key: fs.readFileSync(path.join(home, key)) };
}
const tlsOpts = findCert();
const server = tlsOpts ? https.createServer(tlsOpts, app) : http.createServer(app);
const proto = tlsOpts ? 'https' : 'http';
const wss = new WebSocketServer({ server, path: '/terminal' });
wss.on('connection', (ws) => {
// セッションID・ログファイル作成
const sessionId = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const logPath = path.join(SESSIONS_DIR, `${sessionId}.log`);
const logStream = fs.createWriteStream(logPath, { flags: 'a' });
logStream.write(`=== posimai-dev session ${sessionId} ===\n`);
// セッションID をブラウザに通知
ws.send(JSON.stringify({ type: 'session', id: sessionId }));
const shell = process.env.SHELL || '/bin/bash';
const ptyProc = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: path.join(os.homedir(), 'posimai-project'),
env: { ...process.env, PATH: `${os.homedir()}/.npm-global/bin:${process.env.PATH}` }
});
// 起動時に posimai-project へ自動移動cwd で指定済みだが source bashrc も通す)
setTimeout(() => ptyProc.write(`source ~/.bashrc\n`), 300);
ptyProc.onData((data) => {
if (!logStream.destroyed) logStream.write(data);
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'output', data }));
});
ws.on('message', (raw) => {
try {
const msg = JSON.parse(raw.toString());
if (msg.type === 'input') {
logStream.write(msg.data);
ptyProc.write(msg.data);
}
if (msg.type === 'resize') ptyProc.resize(Number(msg.cols), Number(msg.rows));
} catch {}
});
ws.on('close', () => {
logStream.end(`\n=== session end ===\n`);
try { ptyProc.kill(); } catch {}
});
ptyProc.onExit(() => { try { ws.close(); } catch {} });
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`posimai-dev running on ${proto}://0.0.0.0:${PORT}`);
});