Files
alknet/docs/architecture/napi-and-pubsub.md
glm-5.1 13b0991fb8 Resolve all architecture open questions, add 13 ADRs, update specs
Resolved all 11 open questions based on project guidance:

Transport:
- OQ-01/OQ-07: ACME/Let's Encrypt with domain + IP paths (ADR-008)
- OQ-02: Default to n0 relay, --iroh-relay override (ADR-009)
- OQ-05: Transport chaining supported natively (ADR-010)

Client:
- OQ-06: Programmatic-first API, no ~/.ssh/config (ADR-011)

Server:
- OQ-04: Ed25519 + OpenSSH cert-authority, no password auth (ADR-012)
- OQ-08: fail2ban-friendly logging + built-in rate limiting (ADR-013)

TUN:
- OQ-03/OQ-09: Deferred entirely, recommend tun2proxy (ADR-014)
- tun-shim.md marked deprecated

NAPI:
- OQ-10: Expose both connect() and serve() (ADR-016)
- OQ-11: Use napi-rs for FFI bridge (ADR-015)

Additional ADRs created during review:
- ADR-006: No logging of tunnel destinations (was phantom reference)
- ADR-017: Stealth mode protocol multiplexing
- ADR-018: Control channel for pubsub over SSH

Fixed: ADR-002 status → Superseded, ADR-007 title typo,
WRAUTH_SERVER typo, ADR-005 stale wraith-tun refs,
undefined ACL feature removed from server.md,
--proxy semantic difference documented.
2026-06-01 17:31:28 +00:00

6.5 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 Node.js native addon (via napi-rs) 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 minimal — it exposes transport connections as duplex streams, not the full SSH protocol. The pubsub adapter wraps those streams with EventEnvelope serialization.

Architecture

NAPI Wrapper (napi-rs)

The wrapper uses napi-rs (ADR-015) and exposes two functions (ADR-016):

// @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, or Buffer with key data
  // TLS
  tlsServerName?: string;    // SNI hostname
  insecure?: boolean;        // accept self-signed certs
  // iroh
  irohRelay?: string;       // relay URL (default: n0)
  // Proxy
  proxy?: string;            // upstream SOCKS5/HTTP proxy URL
}

interface WraithServeOptions {
  // Transport
  transport: 'tcp' | 'tls' | 'iroh';
  // Auth
  hostKey?: string;          // path to SSH host key, or Buffer with key data
  authorizedKeys?: string;  // path to authorized_keys, or Buffer with key data
  certAuthority?: string;   // path to CA public key for cert-authority auth
  // TLS
  tlsCert?: string;          // path to TLS cert
  tlsKey?: string;           // path to TLS key
  acmeDomain?: string;      // ACME domain for auto-cert (ADR-008)
  // Listen
  listen?: string;           // listen address (default: 0.0.0.0:22)
  // iroh
  irohRelay?: string;       // relay URL (default: n0)
}

// Returns a Duplex stream for the SSH channel
function connect(options: WraithConnectOptions): Promise<Duplex>;

// Returns a server object with close() and connection events
function serve(options: WraithServeOptions): Promise<WraithServer>;

interface WraithServer {
  close(): Promise<void>;
  onConnection(callback: (stream: Duplex, info: ConnectionInfo) => void): void;
}

The NAPI layer is transport-agnostic — it doesn't know about pubsub's EventEnvelope. The pubsub adapter wraps the Duplex stream to implement TypedEventTarget. This separation ensures the NAPI wrapper is reusable for any stream-based protocol, not tied specifically to pubsub.

Programmatic Configuration (ADR-011)

Both connect() and serve() accept options as plain objects. No file paths are mandatory — keys can be provided as Buffer data directly, making programmatic usage straightforward. Environment variables (WRAITH_SERVER, WRAITH_IDENTITY) provide convenience defaults.

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() or serve()
}

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 uses a reserved direct_tcpip destination (wraith-control:0) for the pubsub control channel (ADR-018). When a client connects to this destination:

  1. The server's channel_open_direct_tcpip handler detects the reserved wraith-control target

When a client connects to this destination: 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 duplex streams, 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.
  • Keys can be provided as file paths or Buffer data, supporting both CLI and programmatic usage patterns (ADR-011).

Open Questions

None — all resolved.

Design Decisions

ADR Decision Summary
007 NAPI exposes single duplex stream No SSH multiplexing in JS, pubsub handles it
011 Programmatic-first API No file-based config; options are structs or env vars
015 napi-rs for FFI Standard Node.js native addon tooling
016 Both connect() and serve() NAPI exposes client and server sides from the start
018 Control channel for pubsub Reserved wraith-control destination for event bus