Skip to content

Protocol Reference

This document describes the tunnel protocol used between the Expose macOS app and server.

Connection

The tunnel uses a single WebSocket connection:

wss://api.{domain}/api/v1/tunnel/connect

All communication happens over this connection using a mix of JSON control messages and binary data frames.

Control Messages

Control messages are JSON text frames with adjacently-tagged format:

{
"type": "MessageType",
"data": { ... }
}

Authentication

Auth (client → server)

{
"type": "Auth",
"data": {
"api_key": "exp_k1_..."
}
}

AuthResult (server → client)

{
"type": "AuthResult",
"data": {
"success": true,
"user_id": "uuid",
"error": null
}
}

Tunnel Registration

RegisterTunnel (client → server)

{
"type": "RegisterTunnel",
"data": {
"subdomain": "myapp",
"local_port": 3000
}
}

TunnelReady (server → client)

{
"type": "TunnelReady",
"data": {
"subdomain": "myapp",
"public_url": "https://myapp.expose.domain.com"
}
}

Stream Lifecycle

StreamOpen (server → client)

{
"type": "StreamOpen",
"data": {
"stream_id": 12345,
"method": "GET",
"path": "/api/users",
"headers": [["Host", "myapp.expose.domain.com"], ["Content-Type", "application/json"]]
}
}

StreamResponse (client → server)

{
"type": "StreamResponse",
"data": {
"stream_id": 12345,
"status": 200,
"headers": [["Content-Type", "application/json"]]
}
}

StreamClose (bidirectional)

{
"type": "StreamClose",
"data": {
"stream_id": 12345
}
}

StreamError (bidirectional)

{
"type": "StreamError",
"data": {
"stream_id": 12345,
"error": "Connection refused"
}
}

WebSocket Upgrade

WsUpgrade (server → client)

{
"type": "WsUpgrade",
"data": {
"stream_id": 12345,
"path": "/ws",
"headers": [...]
}
}

WsUpgradeAccepted (client → server)

{
"type": "WsUpgradeAccepted",
"data": {
"stream_id": 12345
}
}

Keepalive

Heartbeat (bidirectional, every 30 seconds)

{
"type": "Heartbeat",
"data": {}
}

Binary Frames

Binary frames carry request/response bodies and WebSocket data. Format:

┌─────────────┬──────────┬──────────────┬─────────────┐
│ stream_id │ msg_type │ payload_len │ payload │
│ (4 bytes) │ (1 byte) │ (4 bytes) │ (variable) │
└─────────────┴──────────┴──────────────┴─────────────┘

Message Types

TypeValueDescription
RequestBody0x01HTTP request body chunk
ResponseBody0x02HTTP response body chunk
WsFrame0x03WebSocket frame data
EndOfBody0x04Signals end of body

Stream IDs

  • Assigned by server for incoming requests
  • 32-bit unsigned integers
  • Reused after stream closes

Flow Example

Complete request/response flow:

Server Client
│ │
├── StreamOpen (GET /api/users) ────►│
│ │
│ ├── Forward to localhost:3000
│ │
│◄── StreamResponse (200 OK) ────────┤
│◄── Binary (ResponseBody) ──────────┤
│◄── Binary (EndOfBody) ─────────────┤
│ │
├── StreamClose ────────────────────►│

Error Handling

Connection Errors

If the WebSocket connection drops:

  1. Client attempts reconnection
  2. Exponential backoff: 1s, 2s, 4s, 8s, …
  3. Maximum 10 attempts

Stream Errors

If a local request fails:

  1. Client sends StreamError
  2. Server returns 502 Bad Gateway to visitor

Protocol Errors

Invalid messages result in:

  1. Error logged
  2. Connection may be closed for severe errors