feat(v0.3): H2 server — Claude Code hooks → live status side-channel
- types: ClaudeStatus + ServerMessage 'status'; Session.claudeStatus; SessionManager.handleHookEvent - session: inject WEBTERM_SESSION + WEBTERM_HOOK_URL into the spawned shell env so hooks know which tab they belong to - http/hook.ts: pure event→status mapper (PreToolUse→working, PermissionRequest/ Notification:permission_prompt→waiting, Stop/idle_prompt→idle); 6 unit tests - server: POST /hook (loopback-only, express.json 64kb) → manager pushes 'status' - fix: closeIdleConnections() on shutdown so an idle hook keep-alive socket doesn't hang close()/SIGTERM - integration ⑦ (real PTY): attach → POST /hook → ws receives status. 205 tests green.
This commit is contained in:
@@ -35,6 +35,7 @@ import type { IncomingMessage } from 'node:http'
|
||||
import { loadConfig } from './config.js'
|
||||
import { parseClientMessage, serialize } from './protocol.js'
|
||||
import { isOriginAllowed } from './http/origin.js'
|
||||
import { parseHookEvent } from './http/hook.js'
|
||||
import { createSessionManager } from './session/manager.js'
|
||||
import { detachWs, writeInput, resize } from './session/session.js'
|
||||
import type { Config } from './types.js'
|
||||
@@ -77,6 +78,25 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
const publicDir = path.join(__dirname, '..', 'public')
|
||||
app.use(express.static(publicDir))
|
||||
|
||||
// ── Claude Code hook side-channel (H2) ────────────────────────────────────
|
||||
// Hooks running inside a spawned shell POST status here (loopback only — the
|
||||
// shell runs on the host). sessionId arrives in the X-Webterm-Session header.
|
||||
app.post('/hook', express.json({ limit: '64kb' }), (req, res) => {
|
||||
const ip = req.socket.remoteAddress ?? ''
|
||||
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'
|
||||
if (!isLoopback) {
|
||||
res.status(403).end()
|
||||
return
|
||||
}
|
||||
const ev = parseHookEvent(req.header('x-webterm-session'), req.body)
|
||||
if (ev === null) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
manager.handleHookEvent(ev.sessionId, ev.status, ev.detail)
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
// ── HTTP server ───────────────────────────────────────────────────────────
|
||||
const httpServer = createServer(app)
|
||||
|
||||
@@ -219,6 +239,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
console.error('[server] shutdown signal received — cleaning up')
|
||||
doShutdown()
|
||||
httpServer.close(() => process.exit(0))
|
||||
httpServer.closeIdleConnections() // drop idle keep-alive (hook) sockets so close() drains
|
||||
}
|
||||
|
||||
process.on('SIGINT', onSignal)
|
||||
@@ -246,6 +267,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
return new Promise<void>((resolve) => {
|
||||
doShutdown()
|
||||
httpServer.close(() => resolve())
|
||||
// Drop idle keep-alive sockets (e.g. an undici hook connection) so the
|
||||
// close callback can fire instead of waiting for the keep-alive timeout.
|
||||
httpServer.closeIdleConnections()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user