/
threed
/
claud-web
Обзор
Документация
Войти
/
threed
/
claud-web
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
server.js
230 строк
9 KB
Alexander Zelenski
Свои имена вкладок
10 авг 2026, 00:30
10 авг 2026, 00:30
c5b2f82
Код
Авторство
О чём код?
/** * Claud Web — веб-терминал для Claude Code. * * Один процесс: Express (статика + REST) + Socket.IO (PTY-мост к tmux). * Работает за реверс-прокси на любом подпути: все ссылки на фронте * относительные, навигации по URL нет. */ require('dotenv').config({ path: require('path').join(__dirname, '.env') }); const path = require('path'); const http = require('http'); const express = require('express'); const { Server } = require('socket.io'); const pty = require('node-pty'); const { basicAuth, socketAuth, issueToken } = require('./lib/auth'); const projects = require('./lib/projects'); const terminal = require('./lib/terminal'); const push = require('./lib/push'); const watch = require('./lib/watch'); async function main() { // Первичная настройка: если логин/пароль не заданы — спрашиваем интерактивно if (!process.env.AUTH_USERNAME || !process.env.AUTH_PASSWORD) { const { runSetup } = require('./lib/setup'); await runSetup(); } const app = express(); const server = http.createServer(app); const io = new Server(server); // За обратным прокси: нужно для req.secure — по нему решаем, ставить ли // Secure на cookie сессии app.set('trust proxy', 1); app.use(express.json()); // Иконки и манифест — до авторизации. iOS запрашивает apple-touch-icon и // manifest без cookie: под 401 на домашнем экране вместо иконки оказывается // скриншот страницы, а приложение не ставится как PWA. Секретов в них нет. app.use('/icons', express.static(path.join(__dirname, 'public/icons'), { maxAge: '30d' })); app.get('/manifest.webmanifest', (req, res) => { res.sendFile(path.join(__dirname, 'public/manifest.webmanifest')); }); app.use(basicAuth); // Статика: HTML без кеша, вендорные бандлы из node_modules app.use(express.static(path.join(__dirname, 'public'), { setHeaders: (res, filePath) => { if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache'); } })); app.use('/vendor/xterm', express.static(path.join(__dirname, 'node_modules/@xterm/xterm'))); app.use('/vendor/addon-fit', express.static(path.join(__dirname, 'node_modules/@xterm/addon-fit'))); app.get('/vendor/socket.io.js', (req, res) => { res.sendFile(path.join(__dirname, 'node_modules/socket.io/client-dist/socket.io.min.js')); }); // ── REST API ─────────────────────────────────────────────────────────────── const wrap = handler => async (req, res) => { try { res.json({ success: true, ...(await handler(req)) }); } catch (err) { res.status(400).json({ success: false, error: err.message }); } }; app.get('/api/socket-token', (req, res) => res.json({ success: true, token: issueToken() })); app.get('/api/state', wrap(async () => ({ projects: projects.list() }))); app.post('/api/projects', wrap(async req => ({ project: projects.add(req.body?.path) }))); app.delete('/api/projects/:id', wrap(async req => { const killed = await terminal.killAllSessions(req.params.id); projects.remove(req.params.id); return { killedSessions: killed }; })); app.get('/api/fs', wrap(async req => projects.browse(req.query.path))); app.get('/api/projects/:id/sessions', wrap(async req => { if (!projects.get(req.params.id)) throw new Error('Проект не найден'); const sessions = await terminal.listSessions(req.params.id); const busy = watch.busyMap(); return { sessions: sessions.map(s => ({ ...s, busy: Boolean(busy[s.name]) })) }; })); // ── Web Push: уведомления «Клод закончил работу» ────────────────────────── app.get('/api/push/vapid-key', wrap(async () => ({ publicKey: push.publicKey(), subscribers: push.count() }))); app.post('/api/push/subscribe', wrap(async req => ({ subscribers: push.subscribe(req.body?.subscription || req.body) }))); app.post('/api/push/unsubscribe', wrap(async req => { if (typeof req.body?.endpoint !== 'string') throw new Error('Не указан endpoint подписки'); return { removed: push.unsubscribe(req.body.endpoint) }; })); app.post('/api/push/test', wrap(async () => push.broadcast({ title: 'Проверка уведомлений', body: 'Уведомления работают — Claude сообщит, когда закончит работу.', tag: 'push-test' }))); app.post('/api/projects/:id/sessions', wrap(async req => { const project = projects.get(req.params.id); if (!project) throw new Error('Проект не найден'); return { session: await terminal.createSession(project) }; })); app.patch('/api/projects/:id/sessions/:session', wrap(async req => { if (!projects.get(req.params.id)) throw new Error('Проект не найден'); return { title: await terminal.renameSession(req.params.id, req.params.session, req.body?.title) }; })); app.delete('/api/projects/:id/sessions/:session', wrap(async req => { await terminal.killSession(req.params.id, req.params.session); return {}; })); // ── Socket.IO: PTY-мост браузер (xterm.js) ↔ tmux attach ────────────────── io.use(socketAuth); // Кто на какую сессию сейчас смотрит: socket.id -> { session, visible }. // Пуш по такой сессии не шлём — пользователь и так видит результат на // экране, а вибрация «в лицо» раздражает. const watchers = new Map(); watch.setIO(io); watch.setWatchedPredicate(sessionName => { for (const w of watchers.values()) { if (w.session === sessionName && w.visible) return true; } return false; }); watch.start(); io.on('connection', socket => { let term = null; // один активный pty на соединение (активная вкладка) let termData = null; // подписка на вывод: отписываем ДО kill, чтобы хвост // вывода старой вкладки не попал в новую function detachTerminal() { if (!term) return; try { termData?.dispose(); } catch { /* уже отписан */ } termData = null; const victim = term; term = null; try { victim.kill(); } catch { /* уже завершён */ } } socket.on('terminal:attach', async ({ session, cols, rows } = {}) => { try { if (typeof session !== 'string' || !(await terminal.sessionExists(session))) { return socket.emit('terminal:error', `Сессия не найдена: ${session}`); } detachTerminal(); term = pty.spawn('tmux', ['attach-session', '-t', session], { name: 'xterm-256color', cols: Math.max(20, Math.min(500, Number(cols) || 80)), rows: Math.max(5, Math.min(200, Number(rows) || 24)), cwd: process.env.HOME || '/', env: { ...process.env, TERM: 'xterm-256color', LANG: process.env.LANG || 'en_US.UTF-8' } }); watchers.set(socket.id, { session, visible: true }); const current = term; termData = current.onData(data => socket.emit('terminal:data', data)); current.onExit(() => { if (term === current) { term = null; termData = null; socket.emit('terminal:exit', { session }); } }); } catch (err) { socket.emit('terminal:error', err.message); } }); socket.on('terminal:input', data => { if (term && typeof data === 'string') term.write(data); }); socket.on('terminal:resize', ({ cols, rows } = {}) => { if (term && Number(cols) > 0 && Number(rows) > 0) { try { term.resize(Math.max(20, Math.min(500, Number(cols))), Math.max(5, Math.min(200, Number(rows)))); } catch { /* pty мог завершиться */ } } }); // Вкладку свернули/переключили — с этого момента уведомления по её // сессии снова имеют смысл socket.on('terminal:visibility', visible => { const watcher = watchers.get(socket.id); if (watcher) watcher.visible = Boolean(visible); }); socket.on('terminal:detach', () => { watchers.delete(socket.id); detachTerminal(); }); socket.on('disconnect', () => { watchers.delete(socket.id); detachTerminal(); }); }); const PORT = Number(process.env.PORT) || 3777; server.listen(PORT, () => { console.log(`🚀 Claud Web запущен: http://localhost:${PORT}`); console.log(`🔐 Вход: ${process.env.AUTH_USERNAME} / (пароль из .env)`); }); } main().catch(err => { console.error('Ошибка запуска:', err.message); process.exit(1); });