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":
- the server side exposes a net.Listener you hand straight to grpc.Server.Serve (see ListenWebSocket), or an http.Handler + net.Listener pair you mount on an existing mux (see HandlerListener);
- the client side provides a grpc.DialOption whose context dialer opens a WebSocket and presents it to grpc-go as a net.Conn (see DialOption).
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 ¶
- func ConnValue(c net.Conn) (any, bool)
- func DialOption(wsURL string, cfg ClientConfig) (grpc.DialOption, error)
- func FromContext(ctx context.Context) (any, bool)
- func HandlerListener(cfg ServerConfig) (http.Handler, net.Listener)
- func ListenWebSocket(addr string, cfg ServerConfig) (net.Listener, error)
- func ServerCredentials() credentials.TransportCredentials
- type AuthInfo
- type ClientConfig
- type HandshakeError
- type ServerConfig
- type UpgradeError
- type ValueConn
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func ConnValue ¶ added in v0.2.0
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
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.
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.
