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.
Registering WebSocket handlers
Section titled “Registering WebSocket handlers”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}) }), ),)How it works
Section titled “How it works”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. BothInandStateare inferred from the setup function’s signature. The setup function runs after the WebSocket upgrade, receives the parsed input, and returns aStatevalue that is passed to all handlers.WSOn("name", fn)— registers a typed handler for messages with the given type name. TheStateandMsgtype parameters are inferred from the handler function.Statemust match the setup function’s return type.WSSends(variants...)— a[]WSMessageVariantthat registers named send types for auto-wrap envelopes and AsyncAPI schema generation.
Everything is configured in one place — no mutation after construction.
WSOn handlers
Section titled “WSOn handlers”Each WSOn handler has this signature:
func(sender *shiftapi.WSSender, state State, msg Msg) errorsender *WSSender— for sending messages and accessing the connection context viasender.Context()state State— the value returned by the setup function for this connectionmsg 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
Section titled “WSSender”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"}}Context
Section titled “Context”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 parsing
Section titled “Input parsing”Input decoding works identically to Handle — path, 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.
Connection setup and state
Section titled “Connection setup and state”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.
Cleanup on disconnect
Section titled “Cleanup on disconnect”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}Error handling
Section titled “Error handling”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 corresponding4xxxcode. Unregistered errors close withWSStatusInternalErrorand are logged. - Decode errors — malformed payloads are logged and skipped. The connection stays open. Use
WSOnDecodeErrorto 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.
Setup errors
Section titled “Setup errors”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
Section titled “Callbacks”Callbacks are passed as WSHandler values to Websocket(), alongside WSOn handlers. They receive the same (sender, state) pair as message handlers:
WSOnDecodeError
Section titled “WSOnDecodeError”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
Section titled “WSOnUnknownMessage”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) }),)AsyncAPI spec
Section titled “AsyncAPI spec”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.0defaultContentType: application/jsonchannels: /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.
TypeScript client
Section titled “TypeScript client”See WebSocket Client for the full TypeScript client documentation, including the generated websocket() function, reconnection, error handling on the client, and React examples.
Multi-type messages
Section titled “Multi-type messages”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.
Register with WSSends
Section titled “Register with WSSends”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}) }), ),)Wire format
Section titled “Wire format”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"}}Multiple receive handlers
Section titled “Multiple receive handlers”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.
Origin checking
Section titled “Origin checking”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"}, }),)Route options
Section titled “Route options”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"}, }),)| Option | Description |
|---|---|
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():
| Argument | Description |
|---|---|
setup | Connection 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 case | Recommendation |
|---|---|
| Bidirectional real-time (chat, games) | HandleWS — typed handlers, auto dispatch, auto upgrade |
| Server push only (feeds, notifications) | HandleSSE — simpler, works through proxies |
| JSON API endpoint | Handle — let the framework encode the response |
| Custom WebSocket framing | HandleRaw — direct ResponseWriter control |