npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

@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 3001

The 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:

  1. Subprocess: Bun.spawn or node-pty process running the shell
  2. Headless Terminal: @xterm/headless buffer for scrollback and rendering
  3. Serialization: @xterm/addon-serialize for instant snapshots
  4. 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/stream

SSE 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/snapshot

Response:

{
  "data": "...serialized terminal state..."
}

Delete PTY

Kill a specific PTY.

DELETE /session/:sid/pty/:id

List PTYs

List all PTYs for a session.

GET /session/:sid/pty

Response:

{
  "ptys": ["uuid-1", "uuid-2"]
}

Delete All Session PTYs

Kill all PTYs for a session.

DELETE /session/:sid/pty

Implementation 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 minutes

Development

# 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:watch

See 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.target

Security 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

License

MIT

Author

Shun Kakinoki