Files
wraith/docs/architecture/napi-and-pubsub.md
glm-5.1 dad8224686 Add architecture specification for wraith SSH tunnel tool
Docs:
- README.md: index with doc table, ADR table, lifecycle definitions
- overview.md: purpose, exports, dependencies, constraints
- transport.md: Transport trait, TCP/TLS/iroh implementations, stream join
- client.md: SOCKS5 server, port forwarding, channel manager, reconnection
- server.md: auth, channel handling, stealth mode, outbound proxy
- tun-shim.md: separate privileged process, virtual DNS, --unshare mode
- napi-and-pubsub.md: NAPI wrapper, pubsub event target adapter

ADRs:
- 001: Pluggable transport via AsyncRead+AsyncWrite trait
- 002: TUN shim as separate process
- 003: iroh stream via tokio::io::join
- 004: SSH runs over transport, not alongside
- 005: SOCKS5 as primary interface, TUN as add-on
- 006(007): NAPI exposes single duplex stream

Open questions: 11 items covering TLS certs, iroh relay defaults,
Windows TUN, auth expansion, NAPI surface, TCP reconstruction
2026-06-01 15:01:45 +00:00

5.1 KiB

status, last_updated
status last_updated
draft 2026-06-01

NAPI Wrapper & PubSub Event Target

What

Two integration layers that enable TypeScript/JavaScript consumers to use wraith as a transport:

  1. NAPI wrapper (@alkdev/wraith) — A minimal Node.js native addon exposing connect() and serve() that return duplex streams
  2. PubSub event target (@alkdev/pubsub adapter) — An implementation of the TypedEventTarget interface that routes events over wraith's SSH channel

Why

The wraith Rust binary serves CLI users. But the broader ecosystem (pubsub, operations, agent spokes) is TypeScript-first. These integration layers let TypeScript code use wraith's transport without reimplementing SSH.

The NAPI surface is intentionally tiny — it exposes the transport connection, not the full SSH protocol. The pubsub adapter is also minimal — it implements TypedEventTarget and serializes EventEnvelope JSON over the stream.

Architecture

NAPI Wrapper

The wrapper exposes a single function that establishes a wraith connection and returns a Node.js Duplex stream:

// @alkdev/wraith (TypeScript side)

interface WraithConnectOptions {
  // TCP/TLS mode
  server?: string;           // e.g., "example.com:443"
  // iroh mode
  peer?: string;             // iroh EndpointId (hex)
  // Transport
  transport: 'tcp' | 'tls' | 'iroh';
  // Auth
  identity?: string;         // path to SSH key
  // TLS
  tlsServerName?: string;    // SNI hostname
  insecure?: boolean;         // accept self-signed certs
  // iroh
  irohRelay?: string;        // relay URL (default: n0)
}

function connect(options: WraithConnectOptions): Duplex;

The Duplex stream carries raw SSH channel data. On the Rust side, the NAPI function:

  1. Creates a transport (TCP/TLS/iroh) based on options
  2. Establishes an SSH session via client::connect_stream()
  3. Opens a single direct_tcpip channel to a well-known destination (or uses a control protocol)
  4. Returns the channel as a NAPI Buffer stream

Key design decision: The NAPI wrapper does NOT expose the full SSH channel multiplexing API. It returns one duplex stream. If the TypeScript consumer needs multiple logical channels, it multiplexes them itself (e.g., via pubsub's event routing).

PubSub Event Target Adapter

This implements TypedEventTarget from @alkdev/pubsub:

// @alkdev/pubsub (new adapter: event-target-wraith.ts)

export interface WraithEventTargetOptions {
  stream: Duplex;  // from @alkdev/wraith.connect()
}

export interface WraithEventTarget<TEvent extends TypedEvent>
  extends TypedEventTarget<TEvent> {
  close(): void;
}

export function createWraithEventTarget<TEvent extends TypedEvent>(
  options: WraithEventTargetOptions
): WraithEventTarget<TEvent>;

Wire protocol (same as other pubsub adapters):

  • Framing: 4-byte big-endian length prefix + JSON payload
  • Payload: EventEnvelope JSON ({ type, id, payload })
  • Control: __subscribe / __unsubscribe messages for topic-based routing
  • Direction: Bidirectional — dispatchEvent sends, addEventListener subscribes and receives

On the Server Side

The wraith server exposes a "control channel" — a special direct_tcpip destination (e.g., wraith-control:0) that routes to the pubsub event bus instead of a TCP target. When a client connects to this destination:

  1. Server's channel_open_direct_tcpip handler detects the special target
  2. Instead of opening a TCP connection, it bridges the channel to its local pubsub event bus
  3. EventEnvelope JSON flows bidirectionally over the SSH channel

Alternatively, the server can listen on a specific port (e.g., 9736) for the hub's WebSocket server, and wraith simply port-forwards that port.

Direction Agnostic

Because wraith supports both local and remote port forwarding, the event target works in either direction:

  • Worker connects to hub: wraith connect --forward 9736:hub:9736 then create WebSocket event target pointing at ws://localhost:9736
  • Hub connects to worker: wraith connect --remote-forward 9736:worker:9736 — same result, opposite initiator

The pubsub adapter doesn't care which side initiated the SSH session. It just needs a byte stream.

Constraints

  • The NAPI wrapper exposes a single duplex stream, not the full SSH channel API. Multiplexing is done at the pubsub layer.
  • The pubsub wire protocol is length-prefixed JSON, matching the existing adapter pattern. Binary payloads should be base64-encoded in the EventEnvelope.payload.
  • The NAPI binary size will be ~5-10MB (includes russh + tokio + cryptography). The iroh feature adds significant size; it should be an optional feature.

Open Questions

  • OQ-10: Whether the NAPI wrapper should expose raw channel access or a higher-level "send JSON, receive JSON" API
  • OQ-11: Whether to use napi-rs or uniffi for the FFI bridge (napi-rs is more established for Node.js, uniffi supports more targets)

Design Decisions

ADR Decision Summary
007 NAPI exposes single duplex stream No SSH multiplexing in JS, pubsub handles it