grx

Transports

How transports attach to the server and why order matters when more than one matches a request.

A transport is a protocol implementation that sits in front of the executor: HTTP+JSON, WebSocket, SSE, and anything else that can carry GraphQL traffic. They share a tiny interface so the server doesn't need to know what's on the wire - every byte that crosses the network goes through a transport, including the canonical POST /graphql JSON request.

The interface

type Transport interface {
    Match(r *http.Request) bool
    Serve(w http.ResponseWriter, r *http.Request, executor Executor)
}
  • Match is consulted on every incoming request. It must be cheap and side-effect free - typically a header or path check.
  • Serve is only called after Match returned true. The transport owns the response from there; nothing else writes to w.

The server iterates registered transports in order and the first one to match wins. The default http HTTP+JSON transport is appended to the chain automatically, so a plain POST /graphql request always has a handler even when Transports is empty.

Built-in transports

PackageProtocol
httpThe canonical GraphQL-over-HTTP+JSON POST /graphql request. Appended automatically by grx.NewServer; register it explicitly only when you need it to run before another transport that also matches POST.
sseGraphQL over Server-Sent Events. Activates when the client sends Accept: text/event-stream to /graphql.
websocketRFC 6455 WebSockets implementing the graphql-transport-ws subprotocol (the modern graphql-ws library protocol).

Default behaviour with no transports configured: the server still answers POST /graphql (via the auto-appended http transport), the playground, and /favicon.ico.

Splitting query vs subscription paths

If you set server.Config.SubscriptionPath (or grx.WithSubscriptionPath), and it differs from the GraphQL POST path (default /graphql), only concrete *websocket.WebSocketTransport and *sse.Transport values from your Transports slice are moved to the subscription path. The default http transport stays on the GraphQL path.

Many production setups use a single URL for both POST queries and graphql-transport-ws (GraphiQL expects that by default). Splitting paths is optional - use it when your gateway or operations team wants HTTP and long-lived streams explicitly separated.

Registration

import (
    "github.com/grx-gql/grx"
    grxhttp "github.com/grx-gql/grx/http"
    "github.com/grx-gql/grx/sse"
    "github.com/grx-gql/grx/websocket"
)

srv, _ := grx.NewServer(
    grx.WithSchema(graph.Schema()),
    grx.WithTransports(
        websocket.New(websocket.Config{
            ConnectionInitTimeout: 3 * time.Second,
            ReadIdleTimeout:       60 * time.Second,
            WriteTimeout:          10 * time.Second,
            MaxMessageSize:        1 << 20,
            CheckOrigin:           originAllowlist,
            OnConnect:             authorize,
            PingInterval:          25 * time.Second,
        }),
        sse.New(sse.Config{
            MaxActiveSubscriptions: 256,
        }),
        // Optional: register grxhttp.New() yourself when you want to
        // place it ahead of another POST-matching transport.
        // grx.NewServer appends one automatically when you don't.
        grxhttp.New(),
    ),
)

The http package is named http, which collides with the standard library's net/http. Import it under an alias (we use grxhttp in the docs) when you need both packages in the same file.

WebSocket configuration

The websocket.Config struct exposes the production knobs you'd expect from a long-lived connection:

FieldPurpose
ConnectionInitTimeoutMaximum time to wait for the client's connection_init message.
ReadIdleTimeoutCloses connections that haven't sent any frame in this window.
WriteTimeoutPer-frame write deadline; protects the server from slow consumers.
MaxMessageSizeHard limit on a single message; oversize frames cause a 1009 close.
CheckOriginOrigin allowlist hook. Nil accepts every origin; production needs a real allowlist.
OnConnectAuth hook. Receives the init payload, returns the per-connection context and ack data.
PingIntervalIf non-zero, the server emits application-level pings on this cadence.
MaxSubscriptionsCap active subscribe operations per WebSocket; zero means unlimited. When exceeded, the server sends an error and complete for that id.

The framer enforces RFC 6455 strictly: client masking, fragmentation, control-frame size, reserved bits, UTF-8 validation, reserved opcodes, and continuation ordering all map to the spec-defined close codes.

SSE configuration

sse.New() builds a transport with default limits (none). Pass sse.Config{MaxActiveSubscriptions: N} when you need a hard cap on concurrent SSE streams for that transport value: over-limit handshakes receive 429 with a JSON GraphQL error body before the text/event-stream response begins.

The handler accepts both POST requests with a JSON body and GET requests with query=/variables=/operationName= parameters, then streams event: next / event: complete records back.

When to write a custom transport

If you need a wire format the built-in transports don't cover - GraphQL-over-HTTP application/graphql-response+json, multipart for file uploads, gRPC, your own pub/sub bridge - you write a transport. See Custom Transport for a worked example.

Edit this page on GitHub

Last updated on

On this page