@open-composer/server
v0.1.0
Published
Server-first terminal session manager with persistent snapshots and instant recovery over HTTP/SSE.
Readme
OpenComposer Server
Server-first terminal session manager with persistent snapshots and instant recovery over HTTP/SSE.
Overview
OpenComposer Server implements the PTY + persistent session integration design for OpenCode, providing a server-managed PTY service with:
- Server-first architecture: Single point of execution for all terminal sessions
- Persistent snapshots: Instant recovery using xterm serialization
- Real-time streaming: Live PTY output over Server-Sent Events (SSE)
- Session management: Multi-session support with automatic cleanup
- Bun-native: Uses Bun's subprocess API, no native dependencies required
Quick Start
# Install dependencies
bun install
# Start the server
bun run dev
# Or start the server on a custom port
bun run dev --port 3001The server will start on port 3000 by default.
Architecture
Server-First Model
The TUI is a thin client that sends HTTP requests to the server and receives real-time updates over SSE. All tools (bash, edit, webfetch, etc.) and providers run on the server. Results are persisted and streamed to clients.
┌─────────────┐ HTTP/SSE ┌──────────────┐
│ TUI/IDE │ ◄────────────────────────► │ Server │
│ (Client) │ Thin client, │ │
│ │ display only │ ┌────────┐ │
└─────────────┘ │ │ PTY │ │
│ │Service │ │
┌─────────────┐ │ └────────┘ │
│ Browser │ │ │
│ (Client) │ ◄────────────────────────► │ ┌────────┐ │
└─────────────┘ │ │ xterm │ │
│ │headless│ │
│ └────────┘ │
└──────────────┘PTY Architecture
Each PTY session consists of:
- Subprocess: Bun.spawn or node-pty process running the shell
- Headless Terminal: @xterm/headless buffer for scrollback and rendering
- Serialization: @xterm/addon-serialize for instant snapshots
- Streaming: SSE for real-time output delivery
API Reference
Create PTY
Create a new PTY session.
POST /session/:sid/pty
Content-Type: application/json
{
"cmd": ["bash", "-l"],
"cwd": "/home/user",
"env": { "CUSTOM_VAR": "value" },
"cols": 80,
"rows": 24
}Response:
{
"ptyID": "uuid-here"
}Stream PTY Output
Get real-time PTY output via Server-Sent Events.
GET /session/:sid/pty/:id/streamSSE Events:
event: snapshot
data: {"data": "...serialized terminal state..."}
event: data
data: {"data": "...incremental output..."}
event: exit
data: {"code": 0}Write Input
Send input to the PTY (keyboard, commands, etc.).
POST /session/:sid/pty/:id/input
Content-Type: application/json
{
"data": "echo hello\n"
}Resize PTY
Resize the terminal dimensions.
POST /session/:sid/pty/:id/resize
Content-Type: application/json
{
"cols": 120,
"rows": 40
}Get Snapshot
Get a serialized snapshot of the terminal state for instant recovery.
GET /session/:sid/pty/:id/snapshotResponse:
{
"data": "...serialized terminal state..."
}Delete PTY
Kill a specific PTY.
DELETE /session/:sid/pty/:idList PTYs
List all PTYs for a session.
GET /session/:sid/ptyResponse:
{
"ptys": ["uuid-1", "uuid-2"]
}Delete All Session PTYs
Kill all PTYs for a session.
DELETE /session/:sid/ptyImplementation Details
Bun-Native Subprocess
OpenComposer Server uses Bun's native Bun.spawn() API with stdio pipes:
Features:
- No native dependencies (no node-pty)
- No segmentation faults
- Pure Bun implementation
- Simple cross-platform deployment
- Fast process spawning
Limitations:
- Uses pipes instead of true PTY
- No terminal resize signal to subprocess (terminal buffer is resized)
- Some terminal features may not work (e.g., raw mode, signals)
Best for: Most use cases where you need command execution with output capture. The headless xterm terminal provides a consistent rendering layer regardless of the subprocess limitations.
Client Examples
JavaScript/TypeScript
// Create a PTY
const response = await fetch('http://localhost:3000/session/my-session/pty', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cmd: ['bash', '-l'],
cols: 80,
rows: 24,
}),
})
const { ptyID } = await response.json()
// Stream output
const eventSource = new EventSource(
`http://localhost:3000/session/my-session/pty/${ptyID}/stream`
)
eventSource.addEventListener('snapshot', (e) => {
const { data } = JSON.parse(e.data)
console.log('Initial snapshot:', data)
})
eventSource.addEventListener('data', (e) => {
const { data } = JSON.parse(e.data)
process.stdout.write(data)
})
eventSource.addEventListener('exit', (e) => {
const { code } = JSON.parse(e.data)
console.log('PTY exited with code:', code)
eventSource.close()
})
// Write input
await fetch(`http://localhost:3000/session/my-session/pty/${ptyID}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: 'echo hello\n' }),
})React + xterm.js
import { useEffect, useRef, useState } from 'react'
import { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
export function PTYTerminal({ sessionID }: { sessionID: string }) {
const terminalRef = useRef<HTMLDivElement>(null)
const [ptyID, setPtyID] = useState<string | null>(null)
useEffect(() => {
if (!terminalRef.current) return
const term = new Terminal({ cols: 80, rows: 24 })
term.open(terminalRef.current)
// Create PTY
fetch(`http://localhost:3000/session/${sessionID}/pty`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cmd: ['bash', '-l'],
cols: term.cols,
rows: term.rows,
}),
})
.then((r) => r.json())
.then(({ ptyID }) => {
setPtyID(ptyID)
// Stream output
const es = new EventSource(
`http://localhost:3000/session/${sessionID}/pty/${ptyID}/stream`
)
es.addEventListener('snapshot', (e) => {
const { data } = JSON.parse(e.data)
term.write(data)
})
es.addEventListener('data', (e) => {
const { data } = JSON.parse(e.data)
term.write(data)
})
// Handle input
term.onData((data) => {
fetch(`http://localhost:3000/session/${sessionID}/pty/${ptyID}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
})
})
// Handle resize
term.onResize(({ cols, rows }) => {
fetch(`http://localhost:3000/session/${sessionID}/pty/${ptyID}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows }),
})
})
})
return () => {
if (ptyID) {
fetch(`http://localhost:3000/session/${sessionID}/pty/${ptyID}`, {
method: 'DELETE',
})
}
}
}, [sessionID])
return <div ref={terminalRef} />
}Ink (Node.js TUI)
import { Terminal } from '@xterm/headless'
import { useEffect, useState } from 'react'
export function useXtermSSE(sessionID: string) {
const [term] = useState(
() =>
new Terminal({
cols: process.stdout.columns ?? 80,
rows: process.stdout.rows ?? 24,
})
)
useEffect(() => {
let ptyID: string
// Create PTY
fetch(`http://localhost:3000/session/${sessionID}/pty`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cmd: ['bash', '-l'],
cols: term.cols,
rows: term.rows,
}),
})
.then((r) => r.json())
.then(({ ptyID: id }) => {
ptyID = id
const es = new EventSource(
`http://localhost:3000/session/${sessionID}/pty/${ptyID}/stream`
)
es.addEventListener('snapshot', (e: any) => {
const snap = JSON.parse(e.data).data as string
term.write(snap)
})
es.addEventListener('data', (e: any) => {
const { data } = JSON.parse(e.data)
term.write(data)
})
})
return () => {
if (ptyID) {
fetch(`http://localhost:3000/session/${sessionID}/pty/${ptyID}`, {
method: 'DELETE',
})
}
}
}, [sessionID])
return term
}Configuration
Environment Variables
PORT: Server port (default: 3000)
Idle Cleanup
The server automatically cleans up idle PTYs:
- Cleanup interval: 5 minutes
- Max idle time: 30 minutes
Adjust these in src/index-bun.ts:
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes
const MAX_IDLE_MS = 30 * 60 * 1000 // 30 minutesDevelopment
# Type check
bun run type-check
# Run all tests
bun test
# Run E2E tests only
bun run test:e2e
# Run tests with coverage
bun run test:coverage
# Watch mode
bun run test:watchSee TESTING.md for comprehensive testing documentation.
Deployment
Docker
FROM oven/bun:1
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
EXPOSE 3000
CMD ["bun", "run", "start"]Systemd
[Unit]
Description=OpenComposer Server
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/opencomposer
ExecStart=/usr/local/bin/bun run /opt/opencomposer/src/index-bun.ts
Restart=always
[Install]
WantedBy=multi-user.targetSecurity Considerations
- Authentication: Add authentication middleware before deploying
- Rate limiting: Implement rate limiting per session
- Resource limits: Set memory and CPU limits per PTY
- Input validation: All inputs are validated, but review for your use case
- CORS: Configure CORS origins in production (currently set to
*)
References
- OpenCode Server Docs
- Hono - Fast web framework
- Bun - All-in-one JavaScript runtime
- Bun Subprocess - Process spawning API
- xterm.js headless - Terminal emulator
- xterm serialization addon - Snapshot support
- Server-Sent Events (SSE) - Real-time streaming
License
MIT
Author
Shun Kakinoki
