Skip to content

Server

HandleWS is a dedicated handler for WebSocket connections that gives you typed message handlers, automatic upgrade handling, and end-to-end type safety from Go to TypeScript — including a generated websocket helper.

type EchoReply struct {
Text string `json:"text"`
}
type EchoRequest struct {
Text string `json:"text"`
}
shiftapi.HandleWS(api, "GET /ws",
shiftapi.Websocket(
func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) {
return struct{}{}, nil
},
shiftapi.WSSends(shiftapi.WSMessageType[EchoReply]("echo")),
shiftapi.WSOn("echo", func(s *shiftapi.WSSender, _ struct{}, req EchoRequest) error {
return s.Send(EchoReply{Text: "echo: " + req.Text})
}),
),
)

HandleWS uses a typed collector pattern — you pass a setup function, send types, and typed handlers to Websocket() in a single call, then pass the result to HandleWS:

  • Websocket(setup, sends, handlers...) — creates a *WSMessages[In] value. Both In and State are inferred from the setup function’s signature. The setup function runs after the WebSocket upgrade, receives the parsed input, and returns a State value that is passed to all handlers.
  • WSOn("name", fn) — registers a typed handler for messages with the given type name. The State and Msg type parameters are inferred from the handler function. State must match the setup function’s return type.
  • WSSends(variants...) — a []WSMessageVariant that registers named send types for auto-wrap envelopes and AsyncAPI schema generation.

Everything is configured in one place — no mutation after construction.

Each WSOn handler has this signature:

func(sender *shiftapi.WSSender, state State, msg Msg) error
  • sender *WSSender — for sending messages and accessing the connection context via sender.Context()
  • state State — the value returned by the setup function for this connection
  • msg Msg — the decoded message payload

The framework reads {"type": "name", "data": ...} envelopes from the client, matches type to the registered WSOn name, and decodes data into Msg. Unknown types are logged and skipped by default — use WSOnUnknownMessage to customize this.

WSSender provides methods for sending messages and managing the connection:

Writes a JSON-encoded message wrapped in a {"type": name, "data": value} envelope. The event name is automatically determined from the concrete Go type registered via WSSends:

s.Send(EchoReply{Text: "hello"})
// → {"type": "echo", "data": {"text": "hello"}}

Returns the connection’s context, which is cancelled when the connection closes:

ctx := s.Context()

Closes the connection with a status code and reason:

s.Close(shiftapi.WSStatusNormalClosure, "done")

Input decoding works identically to Handlepath, query, and header struct tags and validation rules all apply. Input is parsed from the HTTP request before the connection is established:

type ChatInput struct {
Room string `path:"room" validate:"required"`
Token string `query:"token" validate:"required"`
}
type ChatState struct {
Room string
Token string
}
shiftapi.HandleWS(api, "GET /rooms/{room}",
shiftapi.Websocket(
func(r *http.Request, s *shiftapi.WSSender, in ChatInput) (*ChatState, error) {
// in.Room and in.Token are parsed and validated before upgrade.
return &ChatState{Room: in.Room, Token: in.Token}, nil
},
shiftapi.WSSends(shiftapi.WSMessageType[ChatMessage]("chat")),
shiftapi.WSOn("message", func(s *shiftapi.WSSender, state *ChatState, m UserMessage) error {
return s.Send(ChatMessage{Room: state.Room, Text: m.Text})
}),
),
)

The setup function receives the parsed In value and returns a State that carries any needed data to handlers.

The setup function (the first argument to Websocket) runs after the WebSocket upgrade but before the dispatch loop starts. It returns a State value that is passed to every WSOn handler, providing type-safe per-connection state:

type JoinInput struct {
Room string `path:"room" validate:"required"`
}
type RoomState struct {
Room *Room
User *User
}
shiftapi.HandleWS(api, "GET /rooms/{room}",
shiftapi.Websocket(
func(r *http.Request, s *shiftapi.WSSender, in JoinInput) (*RoomState, error) {
user, err := authenticate(r)
if err != nil {
return nil, err // closes connection with StatusInternalError
}
room := rooms.Join(in.Room, user)
return &RoomState{Room: room, User: user}, nil
},
shiftapi.WSSends(shiftapi.WSMessageType[ChatMessage]("chat")),
shiftapi.WSOn("message", func(s *shiftapi.WSSender, state *RoomState, m UserMessage) error {
return s.Send(ChatMessage{Room: state.Room.Name, User: state.User.Name, Text: m.Text})
}),
),
)

The State type parameter is inferred from the setup function and enforced at compile time across all WSOn handlers — if a handler’s State type doesn’t match the setup function’s return type, the code won’t compile.

If the setup function returns an error, the connection is closed with WSStatusInternalError and the error is logged.

Use struct{} as the state type when no per-connection state is needed.

The request context (r.Context()) is cancelled when the WebSocket connection closes. Use this in the setup function to run cleanup logic:

func(r *http.Request, s *shiftapi.WSSender, in JoinInput) (*RoomState, error) {
room := rooms.Join(in.Room, user)
go func() {
<-r.Context().Done()
rooms.Leave(in.Room, user)
}()
return &RoomState{Room: room}, nil
}

Registered errors — from input validation, setup, or WSOn handlers — are sent as structured error frames before closing with a 4xxx code (e.g. 4422 for validation, 4401 for unauthorized). Error frames use a distinct wire format ({"error": true, "code": 4401, "data": ...}) so the client can always distinguish them from data frames ({"type": "...", "data": ...}), even mid-stream.

  • Registered errors — if any stage returns an error matching a type registered with WithError[T](status) (or a *ValidationError), the error is sent as an error frame and the connection closes with the corresponding 4xxx code. Unregistered errors close with WSStatusInternalError and are logged.
  • Decode errors — malformed payloads are logged and skipped. The connection stays open. Use WSOnDecodeError to customize.
  • WebSocket close — if a handler returns an error that is already a WebSocket close (e.g. client disconnect), the framework does not double-close.

Register error types with WithError and return them from the setup function. The framework sends the error as a typed first frame before closing:

type AuthError struct {
Message string `json:"message"`
Realm string `json:"realm"`
}
func (e *AuthError) Error() string { return e.Message }
shiftapi.HandleWS(api, "GET /ws",
shiftapi.Websocket(
func(r *http.Request, s *shiftapi.WSSender, in ChatInput) (*ChatState, error) {
user, err := authenticate(r, in.Token)
if err != nil {
return nil, &AuthError{Message: "invalid token", Realm: "chat"}
}
return &ChatState{User: user}, nil
},
shiftapi.WSSends(shiftapi.WSMessageType[ChatMessage]("chat")),
shiftapi.WSOn("message", handleMessage),
),
shiftapi.WithError[*AuthError](http.StatusUnauthorized),
)

Error types registered via WithError are included in the generated TypeScript types. See WebSocket Client — Error handling for how errors surface on the client.

Callbacks are passed as WSHandler values to Websocket(), alongside WSOn handlers. They receive the same (sender, state) pair as message handlers:

WSOnDecodeError registers a handler for message payloads that cannot be decoded into the expected type. If not registered, the framework logs the error and continues reading. The connection is never closed for decode errors.

shiftapi.Websocket(
func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) {
return struct{}{}, nil
},
shiftapi.WSSends(shiftapi.WSMessageType[EchoReply]("echo")),
shiftapi.WSOn("echo", func(s *shiftapi.WSSender, _ struct{}, req EchoRequest) error {
return s.Send(EchoReply{Text: "echo: " + req.Text})
}),
shiftapi.WSOnDecodeError(func(s *shiftapi.WSSender, _ struct{}, err *shiftapi.WSDecodeError) {
log.Printf("bad payload for %s: %v", err.MessageType(), err.Unwrap())
}),
)

WSOnUnknownMessage registers a handler for messages whose type field does not match any registered WSOn handler. If not registered, the framework logs the unknown type and continues reading.

shiftapi.Websocket(
func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) {
return struct{}{}, nil
},
shiftapi.WSSends(shiftapi.WSMessageType[EchoReply]("echo")),
shiftapi.WSOn("echo", func(s *shiftapi.WSSender, _ struct{}, req EchoRequest) error {
return s.Send(EchoReply{Text: "echo: " + req.Text})
}),
shiftapi.WSOnUnknownMessage(func(s *shiftapi.WSSender, _ struct{}, msgType string, data json.RawMessage) {
log.Printf("unknown message type: %s", msgType)
}),
)

WebSocket endpoints are documented in an AsyncAPI 2.4 spec served at GET /asyncapi.json. Each HandleWS call creates a channel with subscribe (server→client) and publish (client→server) operations derived from WSSends and WSOn registrations:

shiftapi.HandleWS(api, "GET /ws",
shiftapi.Websocket(
func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) {
return struct{}{}, nil
},
shiftapi.WSSends(shiftapi.WSMessageType[EchoReply]("echo")),
shiftapi.WSOn("echo", func(s *shiftapi.WSSender, _ struct{}, req EchoRequest) error {
return s.Send(EchoReply{Text: "echo: " + req.Text})
}),
),
)

Produces:

asyncapi: 2.4.0
defaultContentType: application/json
channels:
/ws:
subscribe:
message:
name: echo
payload:
$ref: '#/components/schemas/EchoReply'
publish:
message:
name: echo
payload:
$ref: '#/components/schemas/EchoRequest'

Message schemas are also registered in the OpenAPI spec’s components/schemas so the TypeScript codegen can generate types from a single openapi-typescript pass.

See WebSocket Client for the full TypeScript client documentation, including the generated websocket() function, reconnection, error handling on the client, and React examples.

For endpoints that send multiple message types, use WSSends to register each variant. This generates oneOf schemas with a discriminator on the type field, producing TypeScript discriminated unions.

Pass WSMessageType[T] descriptors to declare each named send type. WSSender.Send automatically wraps the value in a {"type": name, "data": value} envelope based on its concrete Go type:

shiftapi.HandleWS(api, "GET /chat",
shiftapi.Websocket(
func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { return struct{}{}, nil },
shiftapi.WSSends(
shiftapi.WSMessageType[ChatMessage]("chat"),
shiftapi.WSMessageType[SystemMessage]("system"),
),
shiftapi.WSOn("message", func(s *shiftapi.WSSender, _ struct{}, m UserMessage) error {
return s.Send(ChatMessage{User: "server", Text: m.Text})
}),
),
)

Data frames use a {type, data} envelope. WSSender.Send wraps the payload automatically:

{"type": "chat", "data": {"user": "server", "text": "hello"}}

The client sends messages in the same format, and the framework dispatches to the matching WSOn handler:

{"type": "message", "data": {"text": "hello"}}

Error frames use a distinct {error, code, data} envelope so the client can always distinguish them from data frames, even mid-stream:

{"error": true, "code": 4401, "data": {"message": "invalid token", "realm": "chat"}}

You can register multiple WSOn handlers for different client message types:

shiftapi.HandleWS(api, "GET /chat",
shiftapi.Websocket(
func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { return struct{}{}, nil },
shiftapi.WSSends(
shiftapi.WSMessageType[ChatMessage]("chat"),
shiftapi.WSMessageType[SystemMessage]("system"),
),
shiftapi.WSOn("message", func(s *shiftapi.WSSender, _ struct{}, m UserMessage) error {
return s.Send(ChatMessage{User: "echo", Text: m.Text})
}),
shiftapi.WSOn("command", func(s *shiftapi.WSSender, _ struct{}, cmd UserCommand) error {
return s.Send(SystemMessage{Info: "executed: " + cmd.Command})
}),
),
)

Each WSOn provides both runtime dispatch and AsyncAPI schema generation — no separate type registration needed for receive messages.

By default, HandleWS rejects WebSocket connections from different origins. In dev mode (shiftapidev build tag, set automatically by the Vite and Next.js plugins), origin checking is disabled so that cross-origin requests from the frontend dev server work without extra configuration.

In production, you must either serve the frontend and API from the same origin or allow specific origins with WithWSAcceptOptions:

shiftapi.HandleWS(api, "GET /ws", ws,
shiftapi.WithWSAcceptOptions(shiftapi.WSAcceptOptions{
OriginPatterns: []string{"example.com"},
}),
)

Standard route options and WS-specific options are passed to HandleWS (not to Websocket):

shiftapi.HandleWS(api, "GET /ws", ws,
shiftapi.WithRouteInfo(shiftapi.RouteInfo{
Summary: "WebSocket chat",
Tags: []string{"chat"},
}),
shiftapi.WithError[*AuthError](http.StatusUnauthorized),
shiftapi.WithMiddleware(auth),
shiftapi.WithWSAcceptOptions(shiftapi.WSAcceptOptions{
OriginPatterns: []string{"example.com"},
}),
)
OptionDescription
WithRouteInfo(info)Set AsyncAPI summary, description, and tags
WithError[T](code)Declare an error type — matched errors from setup are sent as typed first-frame errors with 4xxx close codes. Generates narrowed WSErrorFor<P> types in the TypeScript client.
WithMiddleware(mw...)Apply HTTP middleware
WithWSAcceptOptions(opts)Configure WebSocket upgrade (origins, subprotocols)

The following are arguments to Websocket():

ArgumentDescription
setupConnection setup function — returns (State, error) (required, first arg)
WSSends(...)Server-to-client message types (required, second arg)
WSOn("name", fn)Typed message handler — variadic (required, at least one)
WSOnDecodeError(fn)Handle malformed message payloads (default: log + continue)
WSOnUnknownMessage(fn)Handle unrecognized message types (default: log + continue)

When to use HandleWS vs HandleSSE vs Handle

Section titled “When to use HandleWS vs HandleSSE vs Handle”
Use caseRecommendation
Bidirectional real-time (chat, games)HandleWS — typed handlers, auto dispatch, auto upgrade
Server push only (feeds, notifications)HandleSSE — simpler, works through proxies
JSON API endpointHandle — let the framework encode the response
Custom WebSocket framingHandleRaw — direct ResponseWriter control