websocket

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Jul 13, 2026 License: MIT Imports: 20 Imported by: 0

README

websocket

Package websocket is Credo's server-side adapter over the exact-pinned coder/websocket protocol engine. Credo owns the public message, close, compression, origin, subprotocol, read-limit, and lifecycle policy while the upstream library owns wire-protocol implementation.

Status: Beta / implemented. See the canonical spec, operations guide, and ADR-019.

The default policy is browser same-origin, optional subprotocol negotiation, disabled compression, and a 32 KiB per-message read limit. Origin checks are a browser-CSRF boundary, not authentication.

Minimal managed usage:

ws := websocket.Use(app, websocket.Config{
    AllowedOrigins: []string{"https://app.example.com"},
    Subprotocols:   []string{"events.v1"},
})

app.GET("/events", ws.Handler(func(req *credo.Context, conn *websocket.Conn) error {
    typ, data, err := conn.Read(conn.Context())
    if err != nil {
        return err
    }
    return conn.Write(conn.Context(), typ, data)
}))

Use integrates connection drain with the App lifecycle. When the App is used only as an http.Handler, the caller owns shutdown and must coordinate Server.Shutdown with its http.Server.Shutdown before tearing down shared infrastructure. The guide includes both managed and external-server examples, error-free, complete-with-error, and incomplete outcomes, plus shutdown-budget sizing.

Operational boundaries

  • Origin authorization is a browser-CSRF boundary, not authentication. Browsers cannot attach arbitrary authorization headers to the WebSocket constructor; prefer secure cookies or a short-lived, single-use ticket. Query-string tokens can appear in proxy logs and should be avoided.
  • Use conn.Context() for connection work. Values copied from the request remain available, so a request-scoped database transaction can accidentally stay open for the full connection lifetime; do not put such middleware on a WebSocket route by default. Snapshot immutable user/service values at handler entry and use short repository scopes.
  • Every connection needs an active Read or CloseRead so ping, pong, and close frames are processed. CloseRead rejects unexpected application data with 1008. Credo does not provide an automatic heartbeat; applications should size Ping deadlines against their proxy idle timeout.
  • HTTP timeout/buffering middleware may remove Hijacker support and produces a pre-upgrade 501. HTTP compression middleware is supported, while WebSocket frame compression is controlled separately by CompressionMode.
  • Raw stdlib middleware that hijacks outside Credo tracking, RFC 8441/HTTP/2 WebSockets, hubs/rooms, outbound clients, reconnect, and distributed fan-out are outside the MVP contract.
  • Conn.Unwrap is a borrowed expert escape hatch. Raw calls bypass Credo's validation, error normalization, logging, and close policy; the raw connection must not outlive the synchronous Handler.

Documentation

Overview

Package websocket provides Credo's server-side WebSocket adapter over the exact-pinned github.com/coder/websocket protocol engine.

Create one Server with Use, then register Server.Handler through the normal Credo GET route API. Global, group, route, authentication, rewrite, and access-log middleware retain their normal ordering. Use integrates with the App's pre-infrastructure drain; applications that use an App only as an http.Handler must coordinate Server.Shutdown themselves.

The zero Config is secure and bounded: browser same-origin authorization, optional subprotocol negotiation, disabled compression, and a 32 KiB per-message read limit. Origin checks protect browser handshakes from cross-site abuse, but they are not authentication; applications must still authenticate and authorize connections.

A Conn is borrowed for the synchronous lifetime of a Handler. Its Context is the connection-lifetime context and should be used for WebSocket I/O instead of the HTTP request context. Neither the pooled *credo.Context nor Conn may be retained after the Handler returns. Every connection needs an active Read or CloseRead so control frames are processed.

Example
package main

import (
	"context"
	"fmt"
	"net/http/httptest"
	"strings"

	coderwebsocket "github.com/coder/websocket"

	"github.com/credo-go/credo"
	credows "github.com/credo-go/credo/websocket"
)

func main() {
	app, err := credo.New(credo.WithoutAccessLog())
	if err != nil {
		panic(err)
	}
	ws := credows.Use(app, credows.Config{
		Subprotocols:       []string{"echo.v1"},
		RequireSubprotocol: true,
	})
	app.GET("/echo", ws.Handler(func(_ *credo.Context, conn *credows.Conn) error {
		typ, payload, readErr := conn.Read(conn.Context())
		if readErr != nil {
			return readErr
		}
		return conn.Write(conn.Context(), typ, payload)
	}))

	httpServer := httptest.NewServer(app)
	defer httpServer.Close()
	client, _, err := coderwebsocket.Dial( //nolint:bodyclose // Dial owns the response body.
		context.Background(),
		"ws"+strings.TrimPrefix(httpServer.URL, "http")+"/echo",
		&coderwebsocket.DialOptions{Subprotocols: []string{"echo.v1"}},
	)
	if err != nil {
		panic(err)
	}
	defer func() { _ = client.CloseNow() }()

	if writeErr := client.Write(context.Background(), coderwebsocket.MessageText, []byte("hello")); writeErr != nil {
		panic(writeErr)
	}
	_, payload, err := client.Read(context.Background())
	if err != nil {
		panic(err)
	}
	fmt.Println(string(payload))

}
Output:
hello

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type CloseError

type CloseError struct {
	Code   StatusCode
	Reason string
}

CloseError reports a WebSocket close status received from the peer. Reason is untrusted peer input and must not be logged or displayed without sanitizing. CloseError deliberately does not unwrap an upstream implementation error.

func (CloseError) Error

func (e CloseError) Error() string

Error returns a human-readable close diagnostic. Its text is not a parsing contract; use Code, Reason, or CloseStatus for programmatic inspection.

type CompressionMode

type CompressionMode int

CompressionMode controls per-message deflate negotiation.

const (
	// CompressionDisabled disables per-message compression. This is the default.
	CompressionDisabled CompressionMode = iota
	// CompressionNoContextTakeover resets compression state between messages.
	CompressionNoContextTakeover
	// CompressionContextTakeover reuses compression state between messages.
	CompressionContextTakeover
)

type Config

type Config struct {
	// AllowedOrigins adds exact or one-left-label wildcard browser origins to
	// the same-origin default, for example "https://app.example.com" or
	// "https://*.example.com".
	AllowedOrigins []string
	// Subprotocols lists supported protocols in server preference order.
	Subprotocols []string
	// RequireSubprotocol rejects clients that do not offer a subprotocol.
	RequireSubprotocol bool
	// ReadLimit is the maximum bytes in one message. Zero uses 32 KiB; negative
	// values are invalid. There is no public unlimited setting.
	ReadLimit int64
	// CompressionMode controls per-message deflate negotiation.
	CompressionMode CompressionMode
	// CompressionThreshold is the minimum message size to compress. Zero uses
	// the selected mode's default; it is ignored when compression is disabled.
	CompressionThreshold int
	// InsecureSkipOriginCheck disables browser origin authorization. It is a
	// CSRF footgun and cannot be combined with AllowedOrigins.
	InsecureSkipOriginCheck bool
}

Config configures a WebSocket Server. Its zero value is secure: browser origins must be same-origin, compression is disabled, subprotocols are optional, and each message is limited to 32 KiB.

Example
package main

import (
	"context"

	"github.com/coder/websocket"

	"github.com/credo-go/credo"
	credows "github.com/credo-go/credo/websocket"
)

type publicConnSurface interface {
	Context() context.Context
	Read(context.Context) (credows.MessageType, []byte, error)
	Write(context.Context, credows.MessageType, []byte) error
	Ping(context.Context) error
	CloseRead(context.Context) context.Context
	Close(credows.StatusCode, string) error
	Subprotocol() string
	Unwrap() *websocket.Conn
}

type publicServerSurface interface {
	Handler(credows.Handler) credo.Handler
	Shutdown(context.Context) error
}

var (
	_ publicConnSurface                                   = (*credows.Conn)(nil)
	_ publicServerSurface                                 = (*credows.Server)(nil)
	_ error                                               = credows.CloseError{}
	_ credows.Handler                                     = func(*credo.Context, *credows.Conn) error { return nil }
	_ func(*credo.App, ...credows.Config) *credows.Server = credows.Use
)

func main() {
	_ = credows.Config{
		AllowedOrigins:     []string{"https://app.example.com"},
		Subprotocols:       []string{"events.v1"},
		RequireSubprotocol: true,
		ReadLimit:          1 << 20,
		CompressionMode:    credows.CompressionNoContextTakeover,
	}
}

type Conn

type Conn struct {
	// contains filtered or unexported fields
}

Conn is a Credo-owned façade over an accepted WebSocket connection.

Methods may be called concurrently except that only one Read may be active at a time. CloseRead claims the read side permanently; Read must not be called after it. Concurrent Write calls are serialized by the protocol engine.

func (*Conn) Close

func (c *Conn) Close(code StatusCode, reason string) error

Close performs the WebSocket close handshake. It rejects codes that a server cannot send, invalid UTF-8 reasons, and reasons longer than 123 bytes before writing a frame.

func (*Conn) CloseRead

func (c *Conn) CloseRead(ctx context.Context) context.Context

CloseRead starts processing control frames when the application does not expect more data messages. It permanently claims the read side. The returned context is cancelled when the upstream reader stops; unexpected data closes the connection with StatusPolicyViolation. Its cancellation can be delayed by the upstream protocol engine's bounded close guard.

func (*Conn) Context

func (c *Conn) Context() context.Context

Context returns the connection-lifetime context. It is independent of the HTTP request cancellation and is cancelled when the adapter finishes the connection lifecycle.

func (*Conn) Ping

func (c *Conn) Ping(ctx context.Context) error

Ping sends a ping and waits for its pong response within ctx.

func (*Conn) Read

func (c *Conn) Read(ctx context.Context) (MessageType, []byte, error)

Read reads one text or binary message. Only one Read may be active at a time. Peer close errors are normalized to Credo CloseError values.

func (*Conn) Subprotocol

func (c *Conn) Subprotocol() string

Subprotocol returns the negotiated subprotocol, or an empty string when no subprotocol was negotiated.

func (*Conn) Unwrap

func (c *Conn) Unwrap() *coderwebsocket.Conn

Unwrap returns the underlying coder/websocket connection as an expert escape hatch. Ownership is not transferred: handler-return cleanup, connection context cancellation, and lifecycle tracking still apply. Raw calls bypass Credo's message validation, error normalization, logging, and close policy; the returned connection must not outlive the Handler.

func (*Conn) Write

func (c *Conn) Write(ctx context.Context, typ MessageType, data []byte) error

Write writes one complete text or binary message. Concurrent calls are supported and serialized by the protocol engine.

type Handler

type Handler func(req *credo.Context, conn *Conn) error

Handler handles an accepted WebSocket connection. The handler runs synchronously for the connection lifetime; req and conn must not be retained after it returns.

type MessageType

type MessageType int

MessageType identifies a WebSocket data message type.

const (
	// MessageText identifies a UTF-8 text message.
	MessageText MessageType = iota + 1
	// MessageBinary identifies a binary message.
	MessageBinary
)

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server owns the immutable policy and managed lifecycle state for WebSocket handlers registered through one Credo application. A Server must be created with Use.

func Use

func Use(app *credo.App, cfg ...Config) *Server

Use validates and freezes a WebSocket configuration for app. It accepts zero or one Config value and performs no I/O or DI publication. Invalid configuration, a nil app, or registration after the app is frozen panics as startup misuse.

func (*Server) Handler

func (s *Server) Handler(h Handler) credo.Handler

Handler adapts h into a Credo route handler. It panics for a nil handler. The application handler runs synchronously for the full connection lifetime.

func (*Server) Shutdown

func (s *Server) Shutdown(ctx context.Context) error

Shutdown stops admitting connections, sends active peers a Going Away close, and waits for every synchronous Handler and adapter cleanup to return. The first caller owns the global drain budget. A concurrent caller waits for that result unless its own context ends first; it never changes the owner's budget. Calls made after the owner finishes return its stable result.

If the owner context is cancelled or reaches its deadline before cleanup finishes, Shutdown reports an incomplete drain and the server remains draining until late handlers return. If cleanup finishes but a tracked close operation failed, the server is closed and Shutdown returns that error. A nil ctx is rejected without starting the drain.

type StatusCode

type StatusCode int

StatusCode is a WebSocket close status code.

const (
	// StatusNormalClosure indicates that the connection fulfilled its purpose.
	StatusNormalClosure StatusCode = 1000
	// StatusGoingAway indicates that an endpoint is going away.
	StatusGoingAway StatusCode = 1001
	// StatusProtocolError indicates a protocol error.
	StatusProtocolError StatusCode = 1002
	// StatusUnsupportedData indicates an unsupported data type.
	StatusUnsupportedData StatusCode = 1003
	// StatusNoStatusReceived represents a close frame without a status code.
	// It cannot be sent on the wire.
	StatusNoStatusReceived StatusCode = 1005
	// StatusAbnormalClosure represents an abnormal close without a close frame.
	// It cannot be sent on the wire.
	StatusAbnormalClosure StatusCode = 1006
	// StatusInvalidFramePayloadData indicates invalid frame payload data.
	StatusInvalidFramePayloadData StatusCode = 1007
	// StatusPolicyViolation indicates an application policy violation.
	StatusPolicyViolation StatusCode = 1008
	// StatusMessageTooBig indicates that a message exceeded an endpoint limit.
	StatusMessageTooBig StatusCode = 1009
	// StatusMandatoryExtension indicates a client-required extension was absent.
	// A server cannot send this status.
	StatusMandatoryExtension StatusCode = 1010
	// StatusInternalError indicates an unexpected server condition.
	StatusInternalError StatusCode = 1011
	// StatusServiceRestart indicates that the service is restarting.
	StatusServiceRestart StatusCode = 1012
	// StatusTryAgainLater indicates a temporary server condition.
	StatusTryAgainLater StatusCode = 1013
	// StatusBadGateway indicates an invalid response from an upstream service.
	StatusBadGateway StatusCode = 1014
	// StatusTLSHandshake represents a TLS handshake failure.
	// It cannot be sent on the wire.
	StatusTLSHandshake StatusCode = 1015
)

func CloseStatus

func CloseStatus(err error) StatusCode

CloseStatus returns the first CloseError status found in err's wrapped or joined error tree. It accepts both CloseError values and pointers and returns StatusCode(-1) for nil or non-close errors.

Jump to

Keyboard shortcuts

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