Protocol Specification
Overview
The tunnl protocol is a JSON message-passing protocol over a persistent WebSocket connection. The CLI connects to the server's /ws endpoint, registers a subdomain, and then exchanges request/response pairs for the lifetime of the tunnel session.
The protocol is defined in internal/protocol/ in the monorepo. The server and CLI both import this shared package — a single source of truth enforced by the Go module system.
Message Struct
Every protocol message is a JSON-encoded Message struct:
type Message struct {
Type string `json:"type"`
// Tunnel registration
Subdomain string `json:"subdomain,omitempty"`
Port int `json:"port,omitempty"`
URL string `json:"url,omitempty"`
// HTTP request fields (server → CLI)
RequestID string `json:"request_id,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Headers map[string]string `json:"headers,omitempty"` // deprecated
HeadersMulti map[string][]string `json:"headers_multi,omitempty"`
Body string `json:"body,omitempty"` // base64-encoded
ProtoVersion string `json:"proto_version,omitempty"`
// HTTP response fields (CLI → server)
StatusCode int `json:"status_code,omitempty"`
// Streaming
ChunkIndex int `json:"chunk_index,omitempty"`
IsLast bool `json:"is_last,omitempty"`
// WebSocket passthrough
FrameType int `json:"frame_type,omitempty"`
// Error
Error string `json:"error,omitempty"`
}
Binary bodies (request and response payloads) are base64-encoded in the Body field. The server and CLI both decode/encode this field transparently.
Message Flow
The normal flow for a tunnel session:
CLI Server
│ │
│── WebSocket upgrade ─────────▶│ GET /ws?token=<jwt>
│ │
│── register ────────────────▶ │ {"type":"register","subdomain":"abc123","port":3000}
│◀── registered ────────────── │ {"type":"registered","subdomain":"abc123","url":"https://abc123.example.com"}
│ │
│ │ ← inbound HTTP request arrives
│◀── request ───────────────── │ {"type":"request","request_id":"r1","method":"POST","path":"/webhook",...}
│ │
│ [forward to localhost:3000] │
│ │
│── response ─────────────────▶ │ {"type":"response","request_id":"r1","status_code":200,"body":"...",...}
│ │
│ [server writes HTTP response to original caller]
Message Types
| Type | Direction | Description |
|---|---|---|
register | CLI → Server | Request a tunnel for a subdomain and local port. subdomain and port are set. |
registered | Server → CLI | Tunnel registered successfully. subdomain and url are set. The public URL is ready. |
request | Server → CLI | An inbound HTTP request has arrived. request_id, method, path, headers_multi, and body are set. |
response | CLI → Server | The forwarded response from localhost. request_id, status_code, headers_multi, and body are set. |
error | Either | An error occurred. error contains a human-readable message. The connection may or may not remain open depending on severity. |
stream_chunk | CLI → Server | A chunk of a streaming response body. request_id, chunk_index, body, and is_last are set. |
stream_end | CLI → Server | Marks the end of a streaming response. request_id is set. |
stream_ack | Server → CLI | Back-pressure acknowledgment. Server sends this after receiving a stream_chunk. CLI waits for the ack before sending the next chunk. |
websocket_open | Server → CLI | An inbound WebSocket upgrade request has arrived. Begins a WebSocket passthrough session. |
websocket_frame | Either | A WebSocket frame in a passthrough session. frame_type and body are set. |
websocket_close | Either | The WebSocket passthrough session is closing. |
Streaming
For chunked transfer encoding and SSE responses, the CLI sends a series of stream_chunk messages instead of a single response message. The server uses an HTTP flusher to stream the chunks to the original caller in real time.
Back-pressure is enforced via per-chunk ACK: the CLI sends a chunk, waits for a stream_ack from the server, then sends the next chunk. This prevents the in-memory buffer from growing unboundedly on slow connections.
CLI Server │── stream_chunk(0) ─────────▶ │ │◀── stream_ack(0) ────────── │ │── stream_chunk(1) ─────────▶ │ │◀── stream_ack(1) ────────── │ │── stream_chunk(2, is_last) ▶ │ │── stream_end ──────────────▶ │
WebSocket Passthrough
When an inbound request is a WebSocket upgrade (e.g., ws://abc123.example.com/socket), the server sends a websocket_open message to the CLI. The CLI upgrades the connection to the local server, and subsequent frames are proxied bidirectionally via websocket_frame messages.
The tunnel's WebSocket control channel and the passthrough WebSocket frames both flow over the same control WebSocket connection. The request_id field disambiguates frames belonging to different passthrough sessions.
HTTP/2 Support
HTTP/2 requests are represented using the HeadersMulti field (map[string][]string) instead of the deprecated Headers field (map[string]string). HTTP/2 allows multiple values for a single header key; the flat Headers map cannot represent this correctly.
The ProtoVersion field carries the HTTP protocol version string ("HTTP/1.1", "HTTP/2.0") so the CLI can forward the request using the matching protocol.
Backward Compatibility Policy
The protocol follows these rules for backward compatibility:
- New optional fields may be added to any message type. Older clients that do not understand a field will ignore it (Go JSON unmarshaling ignores unknown fields by default).
- New message types may be added. Older clients that receive an unknown message type should log and discard it.
- Existing fields must not be removed until all clients old enough to depend on them have been retired. The
headersfield is currently deprecated but still emitted alongsideheaders_multifor this reason. - Field semantics must not change. If a field's meaning changes, a new field with a new name must be introduced.
- The server deploys before the CLI. New server-required fields must be optional in the initial server deploy, with full requirement only added after old CLI clients have updated.
headers field is deprecated. Do not use it in new clients. Use headers_multi instead. The deprecated field will remain in the protocol until a version cutoff is established.