How to Implement a RemoteTransport for Nodeterm: WebSocket PTY Integration

To implement a RemoteTransport for nodeterm, create a class that implements the TerminalTransport interface in src/renderer/terminal/transport.ts and routes PTY lifecycle calls over WebSocket to a remote server instead of Electron's IPC.

Nodeterm's architecture deliberately decouples terminal rendering from process creation through the TerminalTransport abstraction. While the default LocalTransport in src/renderer/terminal/local-transport.ts communicates with Electron's main process via IPC, you can implement a RemoteTransport for nodeterm by creating a network-capable class that satisfies the same interface contract.

Understanding the TerminalTransport Abstraction

The TerminalTransport interface defined in src/renderer/terminal/transport.ts serves as the contract between the React UI and the underlying PTY (pseudo-terminal) provider. This abstraction is intentionally free of Electron-specific imports, allowing the same renderer code to run in both desktop and server environments.

The current LocalTransport implementation resides in src/renderer/terminal/local-transport.ts and uses window.nodeTerminal to proxy calls to the main process. To support remote scenarios—such as browser-based server editions or cloud-hosted agents—you must provide an alternative implementation that satisfies the same interface but communicates over network protocols like WebSocket.

The TerminalTransport Interface Contract

According to the source code in src/renderer/terminal/transport.ts, any transport implementation must expose five core methods that manage the PTY lifecycle:

create(sessionId: string, options: PtyCreateOptions): Promise<PtyCreateResult>

Initializes a new PTY session or attaches to an existing tmux session. Returns the initial screen buffer and a session handle.

write(sessionId: string, data: string): Promise<void>

Transmits raw input (keystrokes) to the remote PTY.

resize(sessionId: string, cols: number, rows: number): Promise<void>

Adjusts the terminal dimensions to match the UI viewport.

read(sessionId: string, onData: (chunk: string) => void): () => void

Registers a callback to receive PTY output streams. Returns an unsubscribe function to clean up listeners.

destroy(sessionId: string): Promise<void>

Terminates the PTY process and releases server-side resources.

These signatures are deliberately platform-agnostic, enabling the TerminalNode component in src/renderer/nodes/TerminalNode.tsx to operate identically regardless of whether the PTY runs locally or on a remote host.

Implementing the RemoteTransport Class

To implement a RemoteTransport for nodeterm, create src/renderer/terminal/remote-transport.ts that implements the TerminalTransport interface and manages WebSocket communication.

Establishing the WebSocket Connection

Instantiate a WebSocket connection to your remote server during class construction. The server must expose RPC endpoints equivalent to the Electron main process handlers found in src/main/pty-manager.ts:

import { TerminalTransport, PtyCreateOptions, PtyCreateResult } from './transport';

export class RemoteTransport implements TerminalTransport {
  private ws: WebSocket;
  private listeners = new Map<string, (data: string) => void>();

  constructor(private readonly url: string) {
    this.ws = new WebSocket(url);
    this.ws.onmessage = (ev) => this.handleMessage(JSON.parse(ev.data));
  }
}

Mapping Interface Methods to WebSocket Messages

Implement each TerminalTransport method to serialize calls as JSON messages:

Session Creation:

async create(sessionId: string, opts: PtyCreateOptions): Promise<PtyCreateResult> {
  this.ws.send(JSON.stringify({ type: 'pty:create', sessionId, opts }));
  const resp = await this.waitFor(`pty:create:result:${sessionId}`);
  return resp.result as PtyCreateResult;
}

Input Handling:

async write(sessionId: string, data: string): Promise<void> {
  this.ws.send(JSON.stringify({ type: 'pty:write', sessionId, data }));
}

async resize(sessionId: string, cols: number, rows: number): Promise<void> {
  this.ws.send(JSON.stringify({ type: 'pty:resize', sessionId, cols, rows }));
}

Output Streaming:

read(sessionId: string, onData: (chunk: string) => void): () => void {
  this.listeners.set(sessionId, onData);
  return () => this.listeners.delete(sessionId);
}

private handleMessage(msg: any) {
  if (msg.type === 'pty:output' && this.listeners.has(msg.sessionId)) {
    this.listeners.get(msg.sessionId)!(msg.chunk);
  }
}

Cleanup:

async destroy(sessionId: string): Promise<void> {
  this.ws.send(JSON.stringify({ type: 'pty:destroy', sessionId }));
}

Handling Reconnection Logic

Implement resilience logic similar to src/main/pty-manager.ts by detecting WebSocket disconnects and attempting graceful reconnection. When reconnecting, re-establish any pending read subscriptions to ensure continuous output streaming without UI interruption.

Integrating RemoteTransport into the UI

Modify src/renderer/nodes/TerminalNode.tsx to instantiate either LocalTransport or RemoteTransport based on runtime configuration. Use a useMemo hook to persist the transport instance across renders:

import { LocalTransport } from '../terminal/local-transport';
import { RemoteTransport } from '../terminal/remote-transport';
import { useSettings } from '../state/settings';

export const TerminalNode = ({ node }) => {
  const settings = useSettings();
  
  const transport = useMemo(() => {
    return settings.transportMode === 'remote'
      ? new RemoteTransport(settings.remoteEndpoint)
      : new LocalTransport();
  }, [settings.transportMode, settings.remoteEndpoint]);
  
  // Transport is now ready for PTY lifecycle management
};

This approach allows the TerminalNode component to remain transport-agnostic, switching between local and remote PTYs based on a settings.transportMode flag or URL query parameters.

Server-Side WebSocket Support

The server-side implementation requires adapting the existing PTY service to accept WebSocket frames instead of Electron IPC. Create src/server/websocket-transport.ts to bridge WebSocket messages to the shared ptyService:

import { WebSocketServer } from 'ws';
import { ptyService } from './pty-service';

export const startWebSocketTransport = (httpServer: any) => {
  const wss = new WebSocketServer({ server: httpServer });

  wss.on('connection', (ws) => {
    ws.on('message', async (msg) => {
      const data = JSON.parse(msg.toString());
      
      switch (data.type) {
        case 'pty:create':
          const result = await ptyService.create(data.sessionId, data.opts);
          ws.send(JSON.stringify({ 
            type: `pty:create:result:${data.sessionId}`, 
            result 
          }));
          break;
        case 'pty:write':
          await ptyService.write(data.sessionId, data.data);
          break;
        case 'pty:resize':
          await ptyService.resize(data.sessionId, data.cols, data.rows);
          break;
        case 'pty:destroy':
          await ptyService.destroy(data.sessionId);
          break;
      }
    });
  });
};

The src/server/pty-service.ts module handles the actual PTY management and can be reused from the Server Edition implementation, requiring only this thin adapter layer to support WebSocket clients.

Testing Your Implementation

The repository includes src/renderer/terminal/transport.test.ts which validates the LocalTransport implementation. Create analogous tests for RemoteTransport that spin up a mock WebSocket server using the ws library or node:net:

  • Verify that create() sends the correct JSON payload and parses the server response
  • Confirm that write() forwards data without awaiting a response
  • Test that read() correctly registers callbacks and handles chunked output delivery
  • Validate that destroy() cleans up both local listeners and remote resources

Ensure your transport implementation satisfies the same unit-test contract as LocalTransport to guarantee identical UI behavior across both transport modes.

Summary

Frequently Asked Questions

How does RemoteTransport differ from LocalTransport in nodeterm?

LocalTransport uses Electron's IPC mechanism via window.nodeTerminal to communicate with the main process defined in src/main/pty-manager.ts, spawning PTYs locally using node-pty. RemoteTransport implements the same TerminalTransport interface but routes all calls over WebSocket to a remote server, enabling cloud-hosted terminals or browser-based server editions without local process access.

What network protocol should I use for RemoteTransport?

WebSocket is the recommended protocol for implementing RemoteTransport, as it provides full-duplex communication necessary for real-time terminal input and output streaming. The protocol uses JSON message frames with types pty:create, pty:write, pty:resize, pty:destroy, and pty:output, mapped directly to the interface methods defined in src/renderer/terminal/transport.ts.

How should I handle connection drops in RemoteTransport?

Implement automatic reconnection logic that mirrors the resilience patterns in src/main/pty-manager.ts. Detect WebSocket onclose or onerror events, implement exponential backoff for reconnection attempts, and re-establish any active read subscriptions upon successful reconnection. Store pending writes in a buffer during disconnection to replay once the connection restores.

Can I reuse existing server components for the remote PTY backend?

Yes, the Server Edition's src/server/pty-service.ts can be reused entirely. You only need to create a thin adapter—such as src/server/websocket-transport.ts—that deserializes WebSocket messages and forwards them to the existing service functions. This ensures compatibility with the tmux session management and PTY spawning logic already implemented in the nodeterm codebase.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →