2026-03-30 14:23:28 +00:00
|
|
|
|
'use strict';
|
2026-04-02 11:40:15 +00:00
|
|
|
|
// .env を手動ロード(dotenv 不要)
|
|
|
|
|
|
try {
|
|
|
|
|
|
require('fs').readFileSync(require('path').join(__dirname, '.env'), 'utf8')
|
|
|
|
|
|
.split('\n').forEach(line => {
|
|
|
|
|
|
const eq = line.indexOf('=');
|
|
|
|
|
|
if (eq > 0) process.env[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
|
2026-03-30 14:23:28 +00:00
|
|
|
|
const express = require('express');
|
|
|
|
|
|
const { WebSocketServer } = require('ws');
|
|
|
|
|
|
const pty = require('node-pty');
|
|
|
|
|
|
const http = require('http');
|
2026-03-30 14:45:25 +00:00
|
|
|
|
const https = require('https');
|
|
|
|
|
|
const fs = require('fs');
|
2026-03-30 14:23:28 +00:00
|
|
|
|
const path = require('path');
|
2026-03-30 14:45:25 +00:00
|
|
|
|
const os = require('os');
|
2026-03-31 01:02:55 +00:00
|
|
|
|
const { execSync } = require('child_process');
|
2026-03-30 14:23:28 +00:00
|
|
|
|
|
|
|
|
|
|
const app = express();
|
|
|
|
|
|
const PORT = process.env.PORT || 3333;
|
|
|
|
|
|
|
2026-03-30 15:42:16 +00:00
|
|
|
|
// セッションログ保存ディレクトリ
|
|
|
|
|
|
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());
|
2026-03-30 14:23:28 +00:00
|
|
|
|
app.use(express.static(path.join(__dirname)));
|
|
|
|
|
|
|
2026-04-01 05:59:04 +00:00
|
|
|
|
// /station → station.html エイリアス
|
|
|
|
|
|
app.get('/station', (req, res) => res.sendFile(path.join(__dirname, 'station.html')));
|
2026-04-02 00:36:08 +00:00
|
|
|
|
// /station-b → station-b.html エイリアス
|
|
|
|
|
|
app.get('/station-b', (req, res) => res.sendFile(path.join(__dirname, 'station-b.html')));
|
2026-04-01 05:59:04 +00:00
|
|
|
|
// /sessions → sessions.html エイリアス
|
|
|
|
|
|
app.get('/sessions', (req, res) => res.sendFile(path.join(__dirname, 'sessions.html')));
|
|
|
|
|
|
|
2026-03-31 22:57:27 +00:00
|
|
|
|
// セッション API 用ミドルウェア(Tailscale ネットワーク外からのアクセスを拒否)
|
|
|
|
|
|
function requireLocal(req, res, next) {
|
|
|
|
|
|
const ip = req.ip || req.connection.remoteAddress || '';
|
|
|
|
|
|
const allowed = ip === '::1' || ip === '127.0.0.1' || ip.startsWith('100.');
|
|
|
|
|
|
if (!allowed) return res.status(403).json({ error: 'forbidden' });
|
|
|
|
|
|
next();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 15:42:16 +00:00
|
|
|
|
// セッション一覧 API
|
2026-03-31 22:57:27 +00:00
|
|
|
|
app.get('/api/sessions', requireLocal, (req, res) => {
|
2026-03-30 15:42:16 +00:00
|
|
|
|
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
|
2026-03-31 22:57:27 +00:00
|
|
|
|
app.get('/api/sessions/:id', requireLocal, (req, res) => {
|
2026-03-30 15:42:16 +00:00
|
|
|
|
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'));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
feat: Phase 1 cockpit — net I/O, CPU temp, Gitea commit, keyboard shortcuts, CRIT aurora shift
server.js: add net delta (rx/tx KB/s), CPU temp, /api/gitea-commit proxy.
station-b: net/temp in Ubuntu PC panel, ecosystem bar with latest Gitea
commit, CRIT aurora hue shift (gradual 3s transition to red, then back),
keyboard shortcuts R=refresh B=Design-A F=fullscreen.
station-a: same additions except canvas CRIT effect.
2026-04-02 07:45:45 +00:00
|
|
|
|
// ── ネットワーク I/O デルタ計算 ────────────────────────────────
|
|
|
|
|
|
let _netPrev = null, _netPrevTime = null;
|
|
|
|
|
|
function getNetDelta() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const raw = fs.readFileSync('/proc/net/dev', 'utf8');
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
let rx = 0, tx = 0;
|
|
|
|
|
|
for (const line of raw.trim().split('\n').slice(2)) {
|
|
|
|
|
|
const parts = line.trim().split(/\s+/);
|
|
|
|
|
|
const iface = parts[0].replace(':', '');
|
|
|
|
|
|
if (iface === 'lo') continue;
|
|
|
|
|
|
rx += parseInt(parts[1]) || 0;
|
|
|
|
|
|
tx += parseInt(parts[9]) || 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
let result = null;
|
|
|
|
|
|
if (_netPrev && _netPrevTime) {
|
|
|
|
|
|
const dt = (now - _netPrevTime) / 1000;
|
|
|
|
|
|
result = {
|
|
|
|
|
|
rx_kbps: Math.max(0, Math.round((rx - _netPrev.rx) / dt / 1024)),
|
|
|
|
|
|
tx_kbps: Math.max(0, Math.round((tx - _netPrev.tx) / dt / 1024)),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
_netPrev = { rx, tx }; _netPrevTime = now;
|
|
|
|
|
|
return result;
|
|
|
|
|
|
} catch (_) { return null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── CPU 温度 (/sys/class/thermal/) ─────────────────────────────
|
|
|
|
|
|
function getCpuTemp() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const zones = fs.readdirSync('/sys/class/thermal/').filter(z => z.startsWith('thermal_zone'));
|
|
|
|
|
|
for (const zone of zones) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const type = fs.readFileSync(`/sys/class/thermal/${zone}/type`, 'utf8').trim();
|
|
|
|
|
|
if (['x86_pkg_temp','cpu-thermal','acpitz'].includes(type) || type.startsWith('cpu')) {
|
|
|
|
|
|
return Math.round(parseInt(fs.readFileSync(`/sys/class/thermal/${zone}/temp`, 'utf8')) / 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
return Math.round(parseInt(fs.readFileSync('/sys/class/thermal/thermal_zone0/temp', 'utf8')) / 1000);
|
|
|
|
|
|
} catch (_) { return null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 22:48:01 +00:00
|
|
|
|
// ── ヘルス & メトリクス API (/api/health) ──────────────────────
|
|
|
|
|
|
// Atlas など外部から参照される。CORS ヘッダーを付与して Vercel 上の Atlas からも取得可能にする
|
2026-03-30 22:50:16 +00:00
|
|
|
|
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 };
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 22:48:01 +00:00
|
|
|
|
app.get('/api/health', (req, res) => {
|
|
|
|
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
|
|
|
|
|
|
|
|
|
|
const mem = os.freemem();
|
|
|
|
|
|
const total = os.totalmem();
|
|
|
|
|
|
|
2026-03-30 22:50:16 +00:00
|
|
|
|
// 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;
|
2026-03-30 22:48:01 +00:00
|
|
|
|
|
2026-03-31 01:02:55 +00:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-03-30 22:50:16 +00:00
|
|
|
|
res.json({
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
hostname: os.hostname(),
|
|
|
|
|
|
uptime_s: Math.floor(os.uptime()),
|
|
|
|
|
|
cpu_pct: Math.round(cpuPct),
|
2026-03-31 01:02:55 +00:00
|
|
|
|
cpu_count: cpuCount,
|
|
|
|
|
|
load_avg: loadAvg.map(l => Math.round(l * 100) / 100),
|
2026-03-30 22:50:16 +00:00
|
|
|
|
mem_used_mb: Math.round((total - mem) / 1024 / 1024),
|
|
|
|
|
|
mem_total_mb: Math.round(total / 1024 / 1024),
|
2026-03-31 01:02:55 +00:00
|
|
|
|
disk,
|
feat: Phase 1 cockpit — net I/O, CPU temp, Gitea commit, keyboard shortcuts, CRIT aurora shift
server.js: add net delta (rx/tx KB/s), CPU temp, /api/gitea-commit proxy.
station-b: net/temp in Ubuntu PC panel, ecosystem bar with latest Gitea
commit, CRIT aurora hue shift (gradual 3s transition to red, then back),
keyboard shortcuts R=refresh B=Design-A F=fullscreen.
station-a: same additions except canvas CRIT effect.
2026-04-02 07:45:45 +00:00
|
|
|
|
net: getNetDelta(),
|
|
|
|
|
|
cpu_temp_c: getCpuTemp(),
|
2026-03-30 22:50:16 +00:00
|
|
|
|
active_sessions: wss.clients ? wss.clients.size : 0,
|
|
|
|
|
|
node_version: process.version,
|
|
|
|
|
|
platform: os.platform(),
|
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
|
});
|
|
|
|
|
|
}, 100);
|
2026-03-30 22:48:01 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
feat: Phase 1 cockpit — net I/O, CPU temp, Gitea commit, keyboard shortcuts, CRIT aurora shift
server.js: add net delta (rx/tx KB/s), CPU temp, /api/gitea-commit proxy.
station-b: net/temp in Ubuntu PC panel, ecosystem bar with latest Gitea
commit, CRIT aurora hue shift (gradual 3s transition to red, then back),
keyboard shortcuts R=refresh B=Design-A F=fullscreen.
station-a: same additions except canvas CRIT effect.
2026-04-02 07:45:45 +00:00
|
|
|
|
// ── Gitea 最新コミット (/api/gitea-commit) ─────────────────────
|
|
|
|
|
|
app.get('/api/gitea-commit', async (req, res) => {
|
|
|
|
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
|
|
|
|
try {
|
|
|
|
|
|
const token = process.env.GITEA_TOKEN || '';
|
|
|
|
|
|
const headers = token ? { Authorization: `token ${token}` } : {};
|
|
|
|
|
|
const r = await fetch('http://100.76.7.3:3000/api/v1/repos/mai/posimai-root/commits?limit=1', {
|
|
|
|
|
|
headers, signal: AbortSignal.timeout(3000),
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await r.json();
|
|
|
|
|
|
const c = data[0];
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
sha: c.sha.slice(0, 7),
|
|
|
|
|
|
message: c.commit.message.split('\n')[0].slice(0, 60),
|
|
|
|
|
|
author: c.commit.author.name,
|
|
|
|
|
|
date: c.commit.author.date,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) { res.json({ error: e.message }); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-02 11:04:39 +00:00
|
|
|
|
// ── Vercel 最新デプロイ (/api/vercel-deploys) ──────────────────
|
|
|
|
|
|
app.get('/api/vercel-deploys', async (req, res) => {
|
|
|
|
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
|
|
|
|
const token = process.env.VERCEL_TOKEN || '';
|
|
|
|
|
|
if (!token) return res.status(503).json({ error: 'no token' });
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await fetch('https://api.vercel.com/v6/deployments?limit=1', {
|
|
|
|
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
|
|
|
|
signal: AbortSignal.timeout(6000),
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await r.json();
|
|
|
|
|
|
const d = data.deployments?.[0];
|
|
|
|
|
|
if (!d) return res.json({ error: 'no deployments' });
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
name: d.name,
|
|
|
|
|
|
state: d.state,
|
|
|
|
|
|
url: d.url,
|
2026-04-02 11:40:15 +00:00
|
|
|
|
created: d.created,
|
2026-04-02 11:04:39 +00:00
|
|
|
|
});
|
|
|
|
|
|
} catch (e) { res.status(502).json({ error: e.message }); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-02 10:28:19 +00:00
|
|
|
|
// ── VPS health プロキシ (/api/vps-health) ──────────────────────
|
|
|
|
|
|
// ブラウザから直接叩くと自己署名証明書環境でCORSエラーになるためサーバー経由でプロキシ
|
2026-04-04 14:04:20 +00:00
|
|
|
|
// VPS_API_KEY を .env に設定すると詳細情報(メモリ・ディスク等)を取得できる
|
2026-04-02 10:28:19 +00:00
|
|
|
|
app.get('/api/vps-health', async (req, res) => {
|
|
|
|
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
|
|
|
|
try {
|
2026-04-04 14:04:20 +00:00
|
|
|
|
const headers = {};
|
|
|
|
|
|
if (process.env.VPS_API_KEY) {
|
|
|
|
|
|
headers['Authorization'] = `Bearer ${process.env.VPS_API_KEY}`;
|
|
|
|
|
|
}
|
2026-04-02 10:28:19 +00:00
|
|
|
|
const r = await fetch('https://api.soar-enrich.com/api/health', {
|
2026-04-04 14:04:20 +00:00
|
|
|
|
headers,
|
2026-04-02 10:28:19 +00:00
|
|
|
|
signal: AbortSignal.timeout(6000),
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await r.json();
|
2026-04-02 11:33:21 +00:00
|
|
|
|
res.json({ ok: true, ...data });
|
2026-04-02 10:28:19 +00:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
res.status(502).json({ error: e.message });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-31 12:24:13 +00:00
|
|
|
|
// ── サービス死活チェックプロキシ (/api/check?url=...) ──────────
|
|
|
|
|
|
// ブラウザの mixed-content 制限を回避するためサーバー側から HTTP チェック
|
2026-04-02 23:15:45 +00:00
|
|
|
|
// SSRF 対策: http/https のみ許可、クラウドメタデータ IP をブロック
|
|
|
|
|
|
const BLOCKED_HOSTS = /^(169\.254\.|100\.100\.100\.100|metadata\.google\.internal)/;
|
|
|
|
|
|
function isCheckUrlAllowed(raw) {
|
|
|
|
|
|
let parsed;
|
|
|
|
|
|
try { parsed = new URL(raw); } catch { return false; }
|
|
|
|
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
|
|
|
|
|
|
if (BLOCKED_HOSTS.test(parsed.hostname)) return false;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 12:24:13 +00:00
|
|
|
|
app.get('/api/check', async (req, res) => {
|
|
|
|
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
|
|
|
|
const { url } = req.query;
|
|
|
|
|
|
if (!url) return res.status(400).json({ ok: false, error: 'url required' });
|
2026-04-02 23:15:45 +00:00
|
|
|
|
if (!isCheckUrlAllowed(url)) return res.status(400).json({ ok: false, error: 'url not allowed' });
|
2026-03-31 12:24:13 +00:00
|
|
|
|
const t0 = Date.now();
|
|
|
|
|
|
try {
|
|
|
|
|
|
const ctrl = new AbortController();
|
|
|
|
|
|
const timer = setTimeout(() => ctrl.abort(), 5000);
|
|
|
|
|
|
const r = await fetch(url, { method: 'HEAD', signal: ctrl.signal });
|
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
|
res.json({ ok: true, status: r.status, latency_ms: Date.now() - t0 });
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
res.json({ ok: false, error: e.message, latency_ms: Date.now() - t0 });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-30 15:42:16 +00:00
|
|
|
|
// Tailscale証明書を自動検出
|
2026-03-30 14:45:25 +00:00
|
|
|
|
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';
|
|
|
|
|
|
|
2026-03-30 14:23:28 +00:00
|
|
|
|
const wss = new WebSocketServer({ server, path: '/terminal' });
|
|
|
|
|
|
|
|
|
|
|
|
wss.on('connection', (ws) => {
|
2026-03-30 15:42:16 +00:00
|
|
|
|
// セッション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 }));
|
|
|
|
|
|
|
2026-03-30 14:23:28 +00:00
|
|
|
|
const shell = process.env.SHELL || '/bin/bash';
|
|
|
|
|
|
const ptyProc = pty.spawn(shell, [], {
|
|
|
|
|
|
name: 'xterm-256color',
|
|
|
|
|
|
cols: 80,
|
|
|
|
|
|
rows: 24,
|
2026-03-30 15:42:16 +00:00
|
|
|
|
cwd: path.join(os.homedir(), 'posimai-project'),
|
|
|
|
|
|
env: { ...process.env, PATH: `${os.homedir()}/.npm-global/bin:${process.env.PATH}` }
|
2026-03-30 14:23:28 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-30 15:42:16 +00:00
|
|
|
|
// 起動時に posimai-project へ自動移動(cwd で指定済みだが source bashrc も通す)
|
|
|
|
|
|
setTimeout(() => ptyProc.write(`source ~/.bashrc\n`), 300);
|
|
|
|
|
|
|
2026-03-30 14:23:28 +00:00
|
|
|
|
ptyProc.onData((data) => {
|
2026-03-31 04:37:38 +00:00
|
|
|
|
if (!logStream.destroyed) logStream.write(data);
|
2026-03-30 15:42:16 +00:00
|
|
|
|
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'output', data }));
|
2026-03-30 14:23:28 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
ws.on('message', (raw) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const msg = JSON.parse(raw.toString());
|
2026-03-30 15:42:16 +00:00
|
|
|
|
if (msg.type === 'input') {
|
|
|
|
|
|
logStream.write(msg.data);
|
|
|
|
|
|
ptyProc.write(msg.data);
|
|
|
|
|
|
}
|
2026-03-30 14:23:28 +00:00
|
|
|
|
if (msg.type === 'resize') ptyProc.resize(Number(msg.cols), Number(msg.rows));
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-30 15:42:16 +00:00
|
|
|
|
ws.on('close', () => {
|
|
|
|
|
|
logStream.end(`\n=== session end ===\n`);
|
|
|
|
|
|
try { ptyProc.kill(); } catch {}
|
|
|
|
|
|
});
|
2026-03-30 14:23:28 +00:00
|
|
|
|
ptyProc.onExit(() => { try { ws.close(); } catch {} });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
server.listen(PORT, '0.0.0.0', () => {
|
2026-03-30 14:45:25 +00:00
|
|
|
|
console.log(`posimai-dev running on ${proto}://0.0.0.0:${PORT}`);
|
2026-03-30 14:23:28 +00:00
|
|
|
|
});
|