vibe-remote-relay
v1.1.0
Published
VibeRemote WebSocket relay — bridges host agents (desktop) and mobile clients via binary protocol v3. Ships as a NestJS module (embed) or a standalone CLI (vibe-remote-relay).
Maintainers
Readme
vibe-remote-relay
Korean version: README_KR.md
The VibeRemote WebSocket relay — bridges a PC agent (host) and a mobile client (mobile), forwarding the v3 binary PTY/SSH protocol between them.
Two consumer shapes:
- CLI / Docker — one-liner self-host
- NestJS library — embed as a module inside an existing Nest app
CLI usage (npm)
# one-shot
RELAY_AGENT_SECRET=$(openssl rand -base64 32) npx vibe-remote-relay
# global install
npm i -g vibe-remote-relay
RELAY_AGENT_SECRET=$(openssl rand -base64 32) vibe-remote-relayThe relay listens for WebSocket only on port 8091 by default. It does not expose an HTTP server, so put nginx / caddy in front for TLS termination.
Caddy
relay.example.com {
reverse_proxy ws://localhost:8091
}nginx
location /relay/ {
proxy_pass http://localhost:8091/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}Docker
git clone https://github.com/jun-young1993/vibe-remote.git
cd vibe-remote/packages/relay
cp .env.example .env
echo "RELAY_AGENT_SECRET=$(openssl rand -base64 32)" >> .env
docker compose up -dImage: ghcr.io/jun-young1993/vibe-remote-relay:latest. It is a thin wrapper around the npm package.
NestJS library (embed)
In embed mode you take ownership of pulling values out of process.env (or ConfigService) and passing them in — the relay never reads process.env itself when it is used as a library. CLI / Docker is the only path that auto-loads env via loadRelayConfigFromEnv.
import { Module, Logger } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { RelayModule } from 'vibe-remote-relay';
@Module({
imports: [
ConfigModule.forRoot(),
RelayModule.forRootAsync({
imports: [ConfigModule],
useFactory: (cfg: ConfigService) => ({
port: cfg.get<number>('RELAY_PORT', 8091),
agentSecret: cfg.get<string>('RELAY_AGENT_SECRET', ''),
frameSnapshot: cfg.get<string>('RELAY_FRAME_SNAPSHOT') === 'on',
// 1.1.0 — optional
logger: new Logger('Relay'), // any object satisfying NestJS LoggerService
hooks: {
onConnect: ({ role, token }) => metrics.inc(`relay.connect.${role}`),
onMessage: ({ opcode, byteLen }) => metrics.observe('relay.msg.bytes', byteLen),
onDisconnect: ({ role }) => metrics.inc(`relay.disconnect.${role}`),
onError: ({ role, err }) => sentry.captureException(err, { tags: { role } }),
},
verbose: {
onMessage: cfg.get<string>('RELAY_VERBOSE_ON_MESSAGE') === 'on',
onFrameSnapshotFeed: cfg.get<string>('RELAY_VERBOSE_ON_FRAME_SNAPSHOT_FEED') === 'on',
},
heartbeatIntervalMs: 30_000,
frameSnapshotOptions: { cols: 120, rows: 40 },
maskTokenLength: 8,
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}Or with static config:
RelayModule.forRoot({
port: 8091,
agentSecret: process.env.RELAY_AGENT_SECRET!,
frameSnapshot: true,
})In embed mode the health endpoint (GET /relay/health) is registered automatically.
Environment variables
These are read by the CLI / Docker entrypoint only. Library users pull them themselves and pass values to forRoot/forRootAsync.
| Variable | Required | Default | Description |
|---|---|---|---|
| RELAY_PORT | optional | 8091 | WebSocket bind port |
| RELAY_AGENT_SECRET | required | — | Shared secret for host auth (openssl rand -base64 32). Empty string → onModuleInit throws |
| RELAY_FRAME_SNAPSHOT | optional | unset | Last-frame replay only enabled when set to "on" |
| RELAY_HEARTBEAT_INTERVAL_MS | optional | 30000 | WebSocket ping/pong period. Must be > 0 |
| RELAY_VERBOSE_ON_MESSAGE | optional | unset | "on" → every forwarded message produces a logger.debug line. Off by default to avoid log overload |
| RELAY_VERBOSE_ON_FRAME_SNAPSHOT_FEED | optional | unset | "on" → every frame-snapshot feed produces a logger.debug line. Off by default |
| RELAY_FRAME_SNAPSHOT_COLS | optional | 80 | xterm/headless Terminal columns. Must be > 0 |
| RELAY_FRAME_SNAPSHOT_ROWS | optional | 40 | xterm/headless Terminal rows. Must be > 0 |
| RELAY_FRAME_SNAPSHOT_IDLE_TTL_MS | optional | 1800000 | Snapshot idle TTL (30 min default) |
| RELAY_FRAME_SNAPSHOT_GC_INTERVAL_MS | optional | 60000 | Idle-snapshot GC scan period |
| RELAY_MASK_TOKEN_LENGTH | optional | 8 | First N chars of token shown in audit logs. Must be > 0 |
URL shape
ws[s]://<host>:<port>/<role>/<token>
role = "host" ← PC agent (must send Authorization header)
role = "mobile" ← mobile app
token = arbitrary shared identifier (UUID v4 recommended)The PC agent must send this header when connecting as host:
Authorization: Bearer <RELAY_AGENT_SECRET>Mobile connects with the token only — and is rejected unless host is already OPEN for that token.
Demo with wscat
# 1) host connection
wscat -c "ws://localhost:8091/host/test-token-xyz" \
-H "Authorization: Bearer <RELAY_AGENT_SECRET>"
# 2) from another terminal, mobile connection
wscat -c "ws://localhost:8091/mobile/test-token-xyz"
# 3) binary frames sent by either side are forwarded to the otherSecurity model
See SECURITY.md for details. In short:
- TLS is terminated by an external reverse proxy.
- Host authenticates with
RELAY_AGENT_SECRET; mobile uses the token-as-secret. - E2EE is not implemented — the relay forwards plaintext frames.
Protocol
WebSocket binary frames, v3 opcode format. See vibe-remote-protocol for the full spec and message types.
| Opcode | Meaning |
|---|---|
| 0x00 | PTY raw bytes (host → mobile) |
| 0x01 | APP_JSON message (bidirectional) |
| 0x02 | RESIZE (mobile → host) |
| 0x03 | FRAME_SNAPSHOT (relay → mobile) |
| 0x04 | GRID_SNAPSHOT (host → mobile, A3 path) |
| 0x05 | GRID_DIFF (host → mobile, A3 path) |
| 0xFF | PROTOCOL_ERROR |
Performance notes
The relay forwards every binary frame between host and mobile. Two configuration knobs sit directly on that hot path; treat them carefully.
hooks.onMessageruns once per forwarded message — PTY streams can produce thousands per second. Keep the hook synchronous and cheap (counter increments, ring-buffer pushes). Do not perform HTTP / DB / disk I/O insideonMessage. If you need to ship traffic to an external system, queue inside the hook and drain on a separate timer.verbose.onMessage=truecallslogger.debugper message — fine for short debugging sessions, dangerous in production. A synchronous logger (winston with sync file transport, console in a slow terminal) will create backpressure. Either keep it off in production, or use a logger that samples / batches downstream.
verbose.onFrameSnapshotFeed=true has the same shape: one debug line per feed() call. Useful for reproducing terminal-rendering bugs locally, costly in steady state.
Hooks throwing is safe — the relay wraps each call in try/catch and emits a single warn line — but a throwing hook is still a bug in your code; fix it.
Migration from 1.0.0
Two relevant changes:
- Default logger switched from NestJS
Loggerto aconsoleadapter. This removes the implicit NestJS dependency from the service internals. If you rely on NestJS log formatting, restore it with one line:
Theimport { Logger } from '@nestjs/common'; RelayModule.forRoot({ ..., logger: new Logger('Relay') })RelayLoggerinterface is structurally compatible with NestJSLoggerService, so aLoggerinstance drops in with no adapter. RelayConfig.agentSecretwas already required; the empty-string error message changed fromRELAY_AGENT_SECRET is not settoRelayConfig.agentSecret is required (empty). If you grep error logs for the old wording, update it.
Everything else is additive — new optional fields (hooks, verbose, heartbeatIntervalMs, frameSnapshotOptions, maskTokenLength) default to the 1.0.0 behavior when omitted.
License
MIT — see LICENSE.
Related
- VibeRemote mobile app
- VibeRemote desktop agent
- vibe-remote-protocol — shared binary protocol package
