ws-server-wrapper-go

May 6, 2026 · View on GitHub

A lightweight Go WebSocket library that implements the ws-wrapper protocol. It wraps any WebSocket transport and adds named events, request/response (RPC), and channels on top — enabling seamless communication between Go clients/servers, and ws-wrapper JavaScript clients/servers (browser or Node.js).

Go Reference

Features:

  • Named events — emit an event on one end, handle it on the other
  • Request / response — send a request; the handler's return value is sent back as the response
  • Channels — logically namespace events over a single WebSocket connection
  • Bi-directionality — the server can also request data from clients
  • Go client — dial out to any ws-wrapper server with NewClient / Bind; supports reconnection
  • Pluggable transports — use any WebSocket library you want by implementing the Conn interface (see Custom Adapters for supported libraries). This library by itself has no dependencies.

Install

go get github.com/bminer/ws-server-wrapper-go

Then, install the WebSocket library of your choice along with its corresponding adapter:

go get github.com/coder/websocket
go get github.com/bminer/ws-server-wrapper-go/adapters/coder

Server Quick Start

The example below uses the included coder/websocket adapter:

import (
    "net/http"
    wrapper "github.com/bminer/ws-server-wrapper-go"
    "github.com/bminer/ws-server-wrapper-go/adapters/coder"
    "github.com/coder/websocket"
)

wsServer := wrapper.NewServer()
wsServer.On("echo", func(s string) (string, error) {
    return s, nil
})

http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
    conn, err := websocket.Accept(w, r, nil)
    if err != nil {
        return // websocket.Accept writes the HTTP response
    }
    wsServer.Accept(coder.Wrap(conn))
})
http.ListenAndServe(":8080", nil)

Event Handling

// Handle a named event (fire-and-forget from the client side)
wsServer.On("chat", func(msg string) error {
    fmt.Println("chat:", msg)
    return nil
})

// Handle a request and return a value
wsServer.On("add", func(a, b int) (int, error) {
    return a + b, nil
})

// Access the Client inside the handler via Context
wsServer.On("whoami", func(ctx context.Context) (string, error) {
    client := wrapper.ClientFromContext(ctx)
    return client.Get("username").(string), nil
})

Request / Response (Server → Client)

The server can also send requests to a connected client and await a response:

wsServer.On("open", func(c *wrapper.Client) {
    name, err := c.Request(context.Background(), "getName")
    if err == nil {
        c.Set("name", name)
    }
})

Channels

Namespace events to avoid name collisions:

wsServer.Of("io").On("readFile", func(filename string) ([]byte, error) {
    // TODO: Sanitize `filename` for security reasons
    return os.ReadFile(filename)
})

// Remove all handlers on a channel when it is no longer needed.
ioChannel := wsServer.Of("io")
_ = ioChannel.Close()

Anonymous Channels

Anonymous channels let a handler stream events or exchange multiple messages with the requesting client over a single WebSocket connection. Return a *AnonymousChannel from a handler to signal that data will follow asynchronously:

wsServer.On("subscribe", func(ctx context.Context, topic string) (*wrapper.AnonymousChannel, error) {
    ch := wrapper.Channel(ctx) // obtain the anonymous channel for this request
    go func() {
        defer ch.Close()
        for _, update := range fetchUpdates(topic) {
            if err := ch.Emit(ch.Context(), "data", update); err != nil {
                return // client disconnected or aborted
            }
        }
        ch.Emit(ch.Context(), "end")
    }()
    return ch, nil
})

On the client side, the Request call resolves to a *AnonymousChannel:

resp, err := client.Request(ctx, "subscribe", "news")
if ch, ok := resp.(*wrapper.AnonymousChannel); ok {
    ch.On("data", func(update string) error {
        fmt.Println("update:", update)
        return nil
    })
}

Either side can abort the channel early with ch.Abort(err), which sends a cancel message to the remote end and closes the channel. ch.Context() returns a context that is cancelled when the channel closes or is aborted.

Per-Client Handlers

Register handlers on an individual *Client. They take precedence over server-level handlers for that client:

wsServer.On("open", func(c *wrapper.Client) {
    c.On("secret", func(token string) (string, error) {
        return "ok", nil
    })
})

Custom Adapters

Adapters wrap a WebSocket connection from any library into the Conn interface.

Adapter moduleLibrary
github.com/bminer/ws-server-wrapper-go/adapters/codercoder/websocket
github.com/bminer/ws-server-wrapper-go/adapters/gorillagorilla/websocket

Each adapter is a separate Go module, so you only download the one you need.

Client Mode

Use NewClient and Bind to act as a WebSocket client that speaks the ws-wrapper protocol. Register all handlers before calling Bind so that no inbound message can arrive before a handler is in place:

import (
    wrapper "github.com/bminer/ws-server-wrapper-go"
    "github.com/bminer/ws-server-wrapper-go/adapters/coder"
    "github.com/coder/websocket"
)

client := wrapper.NewClient(nil)
client.On("open", func(c *wrapper.Client) {
    fmt.Println("connected!")
})
client.On("news", func(headline string) error {
    // Server is sending us some news
    fmt.Println("breaking news:", headline)
    return nil
})

conn, _ := websocket.Dial(ctx, "ws://example.com/ws", nil)
client.Bind(coder.Wrap(conn))

If you don't need an "open" handler and just want a quick one-liner, pass the connection directly:

client := wrapper.NewClient(coder.Wrap(conn))

Reconnection

All handlers are stored on the Client and survive across calls to Bind, so reconnection is straightforward. Use the userClosed boolean in the "close" handler to distinguish a user-initiated close from a connection drop — only reconnect when userClosed is false:

client := wrapper.NewClient(nil)

client.On("close", func(c *wrapper.Client, status wrapper.StatusCode, reason string, userClosed bool) {
    if userClosed {
        return // explicit close, don't reconnect
    }
    // Connection dropped; reconnect after a short delay.
    time.AfterFunc(10 * time.Second, func() {
        conn, err := websocket.Dial(ctx, "ws://example.com/ws", nil)
        if err != nil {
            log.Println("reconnect failed:", err)
            return
        }
        c.Bind(coder.Wrap(conn))
    })
})

conn, _ := websocket.Dial(ctx, "ws://example.com/ws", nil)
client.Bind(coder.Wrap(conn))

The userClosed parameter is true only when client.Close() is called explicitly by your own code.

See Also