Protocol Specification

For developers building alternative clients or integrating with the tunnl protocol

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:

Go
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:

sequence
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

TypeDirectionDescription
registerCLI → ServerRequest a tunnel for a subdomain and local port. subdomain and port are set.
registeredServer → CLITunnel registered successfully. subdomain and url are set. The public URL is ready.
requestServer → CLIAn inbound HTTP request has arrived. request_id, method, path, headers_multi, and body are set.
responseCLI → ServerThe forwarded response from localhost. request_id, status_code, headers_multi, and body are set.
errorEitherAn error occurred. error contains a human-readable message. The connection may or may not remain open depending on severity.
stream_chunkCLI → ServerA chunk of a streaming response body. request_id, chunk_index, body, and is_last are set.
stream_endCLI → ServerMarks the end of a streaming response. request_id is set.
stream_ackServer → CLIBack-pressure acknowledgment. Server sends this after receiving a stream_chunk. CLI waits for the ack before sending the next chunk.
websocket_openServer → CLIAn inbound WebSocket upgrade request has arrived. Begins a WebSocket passthrough session.
websocket_frameEitherA WebSocket frame in a passthrough session. frame_type and body are set.
websocket_closeEitherThe 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.

streaming flow
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 headers field is currently deprecated but still emitted alongside headers_multi for 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.
⚠️
The 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.