wstransport

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 16, 2026 License: BSD-3-Clause Imports: 12 Imported by: 0

README

grpc-transports/websocket

websocket

Go Reference CI

WebSocket transport layer for gRPC — the carrier that works inside the browser. The server exposes a net.Listener for a standard grpc.Server; the client provides a grpc.DialOption that tunnels every gRPC channel over a WebSocket. The same client compiles and runs under GOOS=js/GOARCH=wasm, so a Go program in the browser speaks full gRPC — including client-streaming and bidirectional streaming — with no sidecar proxy.

Two client implementations ship side-by-side behind one signature; the build tag picks which.

native (!js) browser (js && wasm)
Dialer github.com/coder/websocketnet.Conn syscall/js WebSocketnet.Conn
Dependencies one Go module zero third-party (stdlib only)
Transport security wss:// via ClientConfig.TLSConfig wss://, owned by the browser
When services, CLIs, tests wasm front-ends, wasm workers

Module

github.com/grpc-transports/websocket

Why WebSocket — the wasm story

grpc-go runs its HTTP/2 framing in userspace over whatever net.Conn the dialer hands it; it never needs an OS socket or access to HTTP/2 trailers. A browser can do neither — no raw TCP, no readable HTTP/2 trailers — which is exactly why grpc-web is limited to unary and server-streaming and needs an Envoy / grpcwebproxy sidecar in front of your server.

Wrapping a browser WebSocket as a net.Conn sidesteps both limits at once:

  • the unmodified grpc-go client transport runs under js/wasm;
  • you get client-streaming and bidirectional streaming, not just unary;
  • the server is a plain grpc.Server behind this package's net.Listener — no sidecar, no second protocol.

For inter-VM gRPC across hosts, prefer wireguard; for human-driven CLI clients, prefer ssh. Reach for websocket whenever one end lives in a browser (or any host that only offers a WebSocket).

When to use

  • A Go wasm front-end (or web worker) that must call gRPC services directly, with streaming
  • Sharing the same gRPC API between a native client and a browser client instead of maintaining a parallel REST/JSON portal
  • Environments where the only reachable channel is an HTTP(S)/WebSocket endpoint (corporate proxies, edge, PaaS)

API

Server
type ServerConfig struct {
    Path           string      // upgrade path (default "/")
    TLSConfig      *tls.Config // non-nil ⇒ serve wss:// (TLS)
    OriginPatterns []string    // allowed browser Origins ("*" to allow all; empty = same-origin)
    Logger         *log.Logger

    // OnUpgrade runs before the upgrade: it can refuse the connection, and the
    // value it returns is carried to every RPC on that connection (see
    // "Per-connection HTTP context" below). Optional — nil keeps the
    // connection exactly as it was.
    OnUpgrade func(*http.Request) (any, error)
}

// ListenWebSocket binds addr, serves the upgrade handler, and returns a
// net.Listener of upgraded WebSocket conns for grpc.Server.Serve. Closing it
// stops the server. Addr() reports the real TCP address (resolves ":0").
func ListenWebSocket(addr string, cfg ServerConfig) (net.Listener, error)

// HandlerListener returns an http.Handler + net.Listener pair, so the gRPC
// endpoint can be mounted on an existing mux — e.g. to serve a wasm client and
// its gRPC endpoint from one origin.
func HandlerListener(cfg ServerConfig) (http.Handler, net.Listener)
Per-connection HTTP context

A browser cannot set an Authorization header on a WebSocket — its credential is a cookie, and cookies live on the upgrade request, which the gRPC layer never sees. OnUpgrade is the seam between the two:

h, lis := wstransport.HandlerListener(wstransport.ServerConfig{
    OnUpgrade: func(r *http.Request) (any, error) {
        // r carries cookies, headers, r.TLS, and whatever an http middleware
        // already put on r.Context().
        c, err := r.Cookie("session")
        if err != nil {
            return nil, &wstransport.UpgradeError{Code: http.StatusUnauthorized}
        }
        return sessions.Lookup(c.Value) // non-nil error ⇒ upgrade refused
    },
})
gs := grpc.NewServer(grpc.Creds(wstransport.ServerCredentials()))

The value then reaches every RPC on that connection, as the AuthInfo of peer.FromContext — read it in one call:

func (s *svc) Method(ctx context.Context, req *pb.Req) (*pb.Resp, error) {
    v, ok := wstransport.FromContext(ctx) // the *Session from OnUpgrade
    ...
}
// UpgradeError refuses an upgrade with a chosen HTTP status (default 403).
// Only Code and Message reach the client; Err is for the server's Logger.
type UpgradeError struct { Code int; Message string; Err error }

// ServerCredentials publishes the OnUpgrade value as gRPC peer AuthInfo.
func ServerCredentials() credentials.TransportCredentials
type AuthInfo struct{ Value any }        // AuthType() == "wstransport-upgrade"
func FromContext(ctx context.Context) (any, bool)

// ConnValue reads the value straight off an accepted conn, for callers who
// prefer their own credentials.TransportCredentials and AuthInfo type.
func ConnValue(c net.Conn) (any, bool)
type ValueConn interface { net.Conn; UpgradeValue() any }

Native clients see a refusal as a typed *HandshakeError carrying the status (errors.As); browsers deliberately hide the failed handshake's status from page scripts, so the js/wasm dialer reports a plain socket error.

Client
type ClientConfig struct {
    Subprotocols []string
    TLSConfig    *tls.Config // native only (browser owns TLS)
    HTTPHeader   http.Header // native only
    Logger       *log.Logger
}

// DialOption returns a grpc.DialOption that tunnels gRPC over a WebSocket to
// wsURL (ws:// or wss://). Identical signature on native and js/wasm. Pair it
// with insecure transport credentials — security is provided by wss.
func DialOption(wsURL string, cfg ClientConfig) (grpc.DialOption, error)

// HandshakeError reports an upgrade the server answered with a status other
// than 101 — e.g. the 401/403 an OnUpgrade hook refuses with. Native only.
type HandshakeError struct { StatusCode int; Status string; Err error }

Usage

Server:

lis, err := wstransport.ListenWebSocket("0.0.0.0:8080", wstransport.ServerConfig{
    OriginPatterns: []string{"app.example"},
    TLSConfig:      myTLS, // wss://
})
if err != nil {
    log.Fatal(err)
}
grpcServer.Serve(lis)

Client (native and wasm — the same code):

opt, err := wstransport.DialOption("wss://app.example:8080", wstransport.ClientConfig{})
if err != nil {
    log.Fatal(err)
}
cc, err := grpc.NewClient("passthrough:///svc",
    grpc.WithTransportCredentials(insecure.NewCredentials()), opt)

Build the browser client with:

GOOS=js GOARCH=wasm go build -o app.wasm ./cmd/app

Testing

  • 100% statement coverage of the native code (task test).
  • Node-driven end-to-end tests compile the real js/wasm client and run it against a live server (task wasm-e2e) — they prove the browser path runs, not merely that it compiles: a full bidirectional stream, a session cookie travelling from the WebSocket handshake into a gRPC service method, and a refused upgrade the browser client observes.
  • CI exercises six architectures (amd64, arm64 native; riscv64, loong64, ppc64le, s390x under QEMU) plus the js/wasm end-to-end job.

License

BSD-3-Clause. See LICENSE.

Documentation

Overview

Package wstransport tunnels gRPC over a WebSocket, so a standard google.golang.org/grpc client and server speak to each other across a carrier the browser can actually use.

Like the sibling grpc-transports carriers (ssh, wireguard), the shape is "one net.Listener, one client dialer":

Why WebSocket, and why it matters for wasm

grpc-go runs its HTTP/2 framing in userspace over whatever net.Conn the dialer returns — it never needs an OS socket or access to HTTP/2 trailers. A browser cannot open raw TCP and cannot read HTTP/2 trailers, which is why grpc-web is limited to unary and server-streaming and needs an Envoy/ grpcwebproxy sidecar. Wrapping a browser WebSocket as a net.Conn sidesteps both limits: the same grpc-go transport runs unmodified under GOOS=js/GOARCH=wasm and gets full client-streaming and bidirectional streaming, with no sidecar — the server is just a grpc.Server behind this package's net.Listener.

Build targets

DialOption has two implementations selected by build tag. On native targets it dials with github.com/coder/websocket; on js/wasm it dials the browser's WebSocket via syscall/js with zero third-party dependencies. The public signature is identical on both, mirroring wireguard's userspace/kernel backend split.

Carrying HTTP state into gRPC

The upgrade request knows things the gRPC layer never sees: cookies, the TLS client certificate, the identity an HTTP middleware just established. A browser in particular cannot set an Authorization header on a WebSocket, so a cookie is often the only credential available.

ServerConfig.OnUpgrade is the seam. It runs before the upgrade, may refuse it (see UpgradeError), and returns a value that is attached to the accepted connection. ServerCredentials republishes that value as gRPC peer AuthInfo, so a service method reads it back with FromContext:

h, lis := wstransport.HandlerListener(wstransport.ServerConfig{
    OnUpgrade: func(r *http.Request) (any, error) {
        c, err := r.Cookie("session")
        if err != nil {
            return nil, &wstransport.UpgradeError{Code: http.StatusUnauthorized}
        }
        return sessions.Lookup(c.Value) // reaches every RPC on this conn
    },
})
gs := grpc.NewServer(grpc.Creds(wstransport.ServerCredentials()))

func (s *svc) Method(ctx context.Context, req *pb.Req) (*pb.Resp, error) {
    v, ok := wstransport.FromContext(ctx) // the session, per connection
    ...
}

Callers who want their own AuthInfo type can skip ServerCredentials and read the value straight off the accepted conn with ConnValue. Leaving OnUpgrade nil keeps the connection exactly as it was: a bare WebSocket conn carrying nothing.

Transport security

The WebSocket layer carries transport security: use a wss:// URL (set ServerConfig.TLSConfig on the server; the browser or ClientConfig.TLSConfig on the client). grpc-go therefore sees a plain net.Conn and should be dialed with insecure transport credentials:

opt, _ := wstransport.DialOption("wss://svc.example/grpc", wstransport.ClientConfig{})
cc, _ := grpc.NewClient("passthrough:///svc",
    grpc.WithTransportCredentials(insecure.NewCredentials()), opt)

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ConnValue added in v0.2.0

func ConnValue(c net.Conn) (any, bool)

ConnValue returns the value ServerConfig.OnUpgrade attached to c, if any. It reports false for connections accepted without an OnUpgrade hook, and for any other net.Conn.

func DialOption

func DialOption(wsURL string, cfg ClientConfig) (grpc.DialOption, error)

DialOption returns a grpc.DialOption that tunnels every gRPC channel over a WebSocket to wsURL (ws:// or wss://). Combine it with insecure transport credentials — transport security is provided by the WebSocket layer (wss).

func FromContext added in v0.2.0

func FromContext(ctx context.Context) (any, bool)

FromContext returns the value ServerConfig.OnUpgrade attached to the connection carrying ctx's RPC. It reports false when the server was not built with ServerCredentials, or when the peer is not a connection of this transport.

func HandlerListener

func HandlerListener(cfg ServerConfig) (http.Handler, net.Listener)

HandlerListener returns an http.Handler that upgrades WebSocket requests on cfg.Path into net.Conns, together with the net.Listener those conns are delivered on. Mount the handler on an existing http.ServeMux (e.g. to serve a wasm client and its gRPC endpoint from one origin) and pass the listener to grpc.Server.Serve.

func ListenWebSocket

func ListenWebSocket(addr string, cfg ServerConfig) (net.Listener, error)

ListenWebSocket binds addr, serves the WebSocket upgrade handler on it, and returns a net.Listener that yields one net.Conn per accepted WebSocket — ready for grpc.Server.Serve. When cfg.TLSConfig is set the server speaks wss:// (TLS). Closing the returned listener stops the server.

func ServerCredentials added in v0.2.0

func ServerCredentials() credentials.TransportCredentials

ServerCredentials returns credentials.TransportCredentials that perform no handshake of their own and instead publish the ServerConfig.OnUpgrade value of each accepted connection as AuthInfo, which grpc-go stores in the peer of every RPC on that connection. Pass them to grpc.NewServer:

h, lis := wstransport.HandlerListener(wstransport.ServerConfig{
    OnUpgrade: authenticate, // returns a *mySession, or an error to refuse
})
gs := grpc.NewServer(grpc.Creds(wstransport.ServerCredentials()))

This is optional sugar: a caller who wants their own AuthInfo type can write their own credentials and read the value off the accepted conn with ConnValue.

These credentials do not authenticate anything by themselves and do not encrypt: serve wss:// (see ServerConfig.TLSConfig) for transport security.

Types

type AuthInfo added in v0.2.0

type AuthInfo struct {
	// Value is the value ServerConfig.OnUpgrade attached to the connection.
	Value any
}

AuthInfo is the credentials.AuthInfo that ServerCredentials attaches to every accepted connection. Value is exactly what ServerConfig.OnUpgrade returned for that connection (nil when no hook was configured), so a service method recovers per-connection HTTP state with:

p, _ := peer.FromContext(ctx)
if ai, ok := p.AuthInfo.(wstransport.AuthInfo); ok {
    session := ai.Value.(*mySession)
}

Use FromContext to do the same in one call.

func (AuthInfo) AuthType added in v0.2.0

func (AuthInfo) AuthType() string

AuthType implements credentials.AuthInfo.

The name reflects where the information comes from — the WebSocket upgrade request — not a cryptographic handshake: transport security is provided by the wss:// layer underneath, which grpc-go never sees.

type ClientConfig

type ClientConfig struct {
	// Subprotocols requested during the WebSocket handshake.
	Subprotocols []string
	// TLSConfig customizes TLS for wss:// dials (native only).
	TLSConfig *tls.Config
	// HTTPHeader is sent with the handshake request (native only).
	HTTPHeader http.Header
	// Logger, when non-nil, receives non-fatal dial diagnostics.
	Logger *log.Logger
}

ClientConfig configures the dialing side of the transport. It is shared by both the native and js/wasm implementations of DialOption; fields noted as native-only are ignored on js/wasm, where the browser owns TLS and headers.

type HandshakeError added in v0.2.0

type HandshakeError struct {
	// StatusCode is the HTTP status the server refused the upgrade with.
	StatusCode int
	// Status is that status as text, e.g. "403 Forbidden".
	Status string
	// Err is the underlying dial error.
	Err error
}

HandshakeError reports a WebSocket handshake the server answered with an HTTP status other than 101 Switching Protocols — what a client sees when a ServerConfig.OnUpgrade hook refuses the upgrade. Recover the status with errors.As to tell "your session expired" (401/403) from "the server is down":

var he *wstransport.HandshakeError
if errors.As(err, &he) && he.StatusCode == http.StatusUnauthorized {
    reauthenticate()
}

Native dials only: a browser deliberately hides a failed handshake's status from page scripts, so the js/wasm dialer reports a plain error.

func (*HandshakeError) Error added in v0.2.0

func (e *HandshakeError) Error() string

func (*HandshakeError) Unwrap added in v0.2.0

func (e *HandshakeError) Unwrap() error

Unwrap exposes the underlying dial error to errors.Is/errors.As.

type ServerConfig

type ServerConfig struct {
	// Path is the HTTP path WebSocket upgrades are accepted on. Empty means "/".
	Path string
	// TLSConfig, when non-nil, makes [ListenWebSocket] serve over TLS (wss://).
	TLSConfig *tls.Config
	// OriginPatterns is passed to the WebSocket handshake to authorize browser
	// Origins. Empty means same-origin only; use []string{"*"} to allow all.
	OriginPatterns []string
	// Logger, when non-nil, receives non-fatal handshake diagnostics.
	Logger *log.Logger

	// OnUpgrade, when non-nil, is called with the HTTP request carrying the
	// WebSocket handshake, before the upgrade is performed. It is the seam
	// between the HTTP layer and the gRPC layer: everything the request knows
	// about the caller — cookies, headers, the TLS client certificate, the
	// identity an HTTP middleware just established in r.Context() — is
	// otherwise lost once the connection becomes a stream of gRPC frames.
	//
	// The value it returns is attached to the accepted net.Conn and can be
	// recovered from that conn with [ConnValue]; combined with
	// [ServerCredentials] it reaches a service method as the AuthInfo of
	// peer.FromContext(ctx). The value is opaque to this package: use whatever
	// type the application wants (a session, a user ID, the *http.Request
	// itself).
	//
	// Returning a non-nil error refuses the upgrade: no WebSocket is created
	// and the request is answered with an HTTP status — 403 Forbidden, or the
	// status carried by an [UpgradeError]. The error is reported to Logger.
	//
	// Leaving OnUpgrade nil keeps the previous behaviour exactly: the accepted
	// conn is the bare WebSocket conn and carries no value.
	OnUpgrade func(*http.Request) (any, error)
}

ServerConfig configures the WebSocket-accepting side of the transport.

type UpgradeError added in v0.2.0

type UpgradeError struct {
	// Code is the HTTP status sent to the client. Zero means
	// http.StatusForbidden.
	Code int
	// Message is the HTTP response body. Empty means http.StatusText(Code).
	Message string
	// Err, when non-nil, is the underlying cause. It is logged, never sent.
	Err error
}

UpgradeError refuses a WebSocket upgrade with a specific HTTP status. Return one from ServerConfig.OnUpgrade when the default 403 Forbidden is not the right answer — 401 for a missing session, 404 to hide the endpoint's existence, 503 while draining:

OnUpgrade: func(r *http.Request) (any, error) {
    c, err := r.Cookie("session")
    if err != nil {
        return nil, &wstransport.UpgradeError{Code: http.StatusUnauthorized}
    }
    return lookupSession(c.Value)
}

Only Code and Message reach the client; Err is for the server's Logger.

func (*UpgradeError) Error added in v0.2.0

func (e *UpgradeError) Error() string

func (*UpgradeError) Unwrap added in v0.2.0

func (e *UpgradeError) Unwrap() error

Unwrap exposes the underlying cause to errors.Is/errors.As.

type ValueConn added in v0.2.0

type ValueConn interface {
	net.Conn
	// UpgradeValue returns the value OnUpgrade attached to this connection.
	UpgradeValue() any
}

ValueConn is implemented by the net.Conns delivered by this package's listeners when ServerConfig.OnUpgrade is set. A credentials.TransportCredentials can type-assert an accepted conn to it in its ServerHandshake and turn the value into gRPC peer AuthInfo — which is what ServerCredentials does.

Directories

Path Synopsis
Command wasmtest is the browser-side half of the wasm end-to-end tests.
Command wasmtest is the browser-side half of the wasm end-to-end tests.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL