A lightweight, isomorphic library that brings named events, Promise-based requests, channels, and more to native WebSockets — with first-class support for web browsers, Node.js, and Go.
Raw WebSockets give you one primitive: send(). ws-wrapper builds a practical
communication layer on top of that, so instead of parsing and routing raw
messages yourself, you get:
- Named events – emit an event on one end, handle it on the other (similar to Socket.IO)
- Request / response – send a request and get back a Promise that resolves (or rejects) with the remote handler's return value
- Channels – logically namespace events over a single WebSocket connection
- Streaming – anonymous (request-scoped) channels permit streaming / iterator patterns
- Cancellation – cancel in-flight requests using the standard
AbortSignalAPI, with cooperative cancellation support on the remote end - Bi-directionality – clients can request data from the server, and the server can also request data from clients
By default, the wire protocol is a thin JSON layer over the native WebSocket,
keeping everything interoperable across JavaScript (browser or Node.js) and Go.
If needed, you can plug in custom messageEncode / messageDecode functions to
handle protocol frames (for example, to send binary frames).
Socket.IO is great, but it lacks a few features and ships with the heavier engine.io transport stack. If you're already using a plain WebSocket, ws-wrapper gives you the event handling and request/response patterns you actually want – without the overhead. The entire library and its dependencies weigh under 12 KB minified (under 4 KB minified and gzipped).
| Feature | ws-wrapper | Socket.IO | ws | SockJS |
|---|---|---|---|---|
| Uses native WebSocket | ✅ | ❌ 1 | ✅ | ❌ 1 |
| Unnecessary HTTP polling fallback | ❌ | ✅ | ❌ | ✅ |
| Auto-reconnect | ❌2 | ✅ | ❌ | ✅ |
| Named events | ✅ | ✅ | ❌ | ❌ |
| Request / response | ✅ | ✅ | ❌ | ❌ |
| Channels / rooms 3 | ✅ | ✅ | ❌ | ❌ |
| Streaming | ✅ | ❌ | ❌ | ❌ |
| Middleware | ✅ | ✅ | ❌ | ❌ |
| Request cancellation (i.e. AbortSignal) | ✅ | ❌ | ❌ | ❌ |
| Custom encoding | ✅ | ✅ | ✅ | ❌ |
| Browser bundle (min + gzip) | ~4 KB | ~25 KB | N/A | ~8 KB |
Node.js / Browser
npm install ws-wrapper
or for Node.js servers, use the recommended ws-server-wrapper library:
npm install ws-server-wrapper
Go server (use with ws-server-wrapper-go)
go get github.com/bminer/ws-server-wrapper-go
ws-wrapper is an isomorphic ES module, so it works in Node.js and in the browser (with or without a bundler like Webpack or Parcel.js).
Check out the example-app for a sample chat application (recommended).
// Use a bundler to make the next line of code "work" on the browserimportWebSocketWrapperfrom"ws-wrapper"// Create a new socketconstsocket=newWebSocketWrapper(newWebSocket("ws://"+location.hostname))// Now use the WebSocketWrapper API... `socket.emit` for examplesocket.emit("msg","my_name","This is a test message")// See additional examples below...Note: This library is designed to work with all modern browsers, but if you need support for older browsers, try using a code transpiler like Babel.
We recommend using ws-server-wrapper to wrap the WebSocketServer. See the ws-server-wrapper README for more details.
If you don't want to use ws-server-wrapper, you can wrap the WebSocket yourself once a new WebSocket connects like this:
import{WebSocketServer}from"ws"importWebSocketWrapperfrom"ws-wrapper"varwss=newWebSocketServer({port: 3000})wss.on("connection",(socket)=>{socket=newWebSocketWrapper(socket)// ...})Use ws-server-wrapper-go to wrap your favorite WebSocket library. The example below uses the coder/websocket adapter:
import (
"log""net/http"
wrapper "github.com/bminer/ws-server-wrapper-go""github.com/bminer/ws-server-wrapper-go/adapters/coder""github.com/coder/websocket"
)
funcmain() {
wsServer:=wrapper.NewServer()
// Register an event handler; return values are sent back as a responsewsServer.On("echo", func(sstring) (string, error) {
returns, nil
})
// Create HTTP server that accepts WebSocket connections on /http.HandleFunc("/", func(w http.ResponseWriter, r*http.Request) {
conn, err:=websocket.Accept(w, r, nil)
iferr!=nil {
return
}
wsServer.Accept(coder.Wrap(conn))
})
log.Fatal(http.ListenAndServe(":8080", nil))
}See the ws-server-wrapper-go repository for a complete example and other adapter options.
Please implement ws-wrapper in your favorite language, and let me know about it! I'll give you beer!
It's what you'd expect of an event handler API.
Call on or once to bind an event handler to the wrapper or to a channel.
Call emit to send an event.
Server-side Example (without using ws-server-wrapper):
import{WebSocketServer}from"ws"importWebSocketWrapperfrom"ws-wrapper"varwss=newWebSocketServer({port: 3000})varsockets=newSet()wss.on("connection",(socket)=>{varsocket=newWebSocketWrapper(socket)sockets.add(socket)socket.on("msg",function(from,msg){// `this` refers to the WebSocketWrapper instanceconsole.log(`Received message from ${from}: ${msg}`)// Relay message to all clientssockets.forEach((socket)=>{socket.emit("msg",from,msg)})})socket.on("disconnect",()=>{sockets.delete(socket)})})Client-side Example:
// Use a bundler to make the next line of code "work" on the browserimportWebSocketWrapperfrom"ws-wrapper"// Establish connectionvarsocket=newWebSocketWrapper(newWebSocket("ws://"+location.host))// Add "msg" event handlersocket.on("msg",function(from,msg){console.log(`Received message from ${from}: ${msg}`)})// Emit "msg" eventsocket.emit("msg","my_name","This is a test message")Note: By default, this module uses JSON.stringify / JSON.parse to encode
protocol data over the raw WebSocket connection. This means that encoding
circular references is not supported out of the box. You can override this with
the messageEncode / messageDecode constructor options.
Just like in socket.io, you can "namespace" your events using channels. When sending messages to multiple channels, the same WebSocket connection is reused, but the events are logically separated into their appropriate channels.
By default, calling emit directly on a WebSocketWrapper instance will send the
message over the "default" channel. To send a message over a channel named
"foo", just call socket.of("foo").emit("eventName", "yourData").
Event handlers can return values or Promises to respond to requests. The response is sent back to the remote end.
The example below shows the client requesting data from the server, but ws-wrapper also allows servers to request data from the client.
Server-side Example (without using ws-server-wrapper):
importfsfrom"node:fs"import{WebSocketServer}from"ws"importWebSocketWrapperfrom"ws-wrapper"constwss=newWebSocketServer({port: 3000})constsockets=newSet()wss.on("connection",(socket)=>{socket=newWebSocketWrapper(socket)sockets.add(socket)socket.on("userCount",()=>{// Return value is sent back to the clientreturnsockets.size})socket.on("readFile",(path)=>{// We can return a Promise that eventually resolvesreturnnewPromise((resolve,reject)=>{// TODO: `path` should be sanitized for security reasonsfs.readFile(path,(err,data)=>{// `err` or `data` are now sent back to the clientif(err)reject(err)elseresolve(data.toString("utf8"))})})})socket.on("disconnect",()=>{sockets.delete(socket)})})Client-side Example:
// Assuming WebSocketWrapper is somehow available to this scope...constsocket=newWebSocketWrapper(newWebSocket("ws://"+location.host))varp=socket.request("userCount")// `p` is a Promise that will resolve when the server responds...p.then((count)=>{console.log("User count: "+count)}).catch((err)=>{console.error("An error occurred while getting the user count:",err)})socket.request("readFile","/etc/issue").then((data)=>{console.log("File contents:",data)}).catch((err)=>{console.error("Error reading file:",err)})Call .timeout(ms) directly before .request(...) like this:
constpromise=socket.timeout(5*1000).request("readFile","/etc/issue")The timeout function affects the next call of request.
Starting in version 4, ws-wrapper supports request cancellation using the Web
standard AbortSignal API. This allows you to cancel in-flight requests from
either the client or server side.
// Send a request that can be cancelledconstcontroller=newAbortController()constpromise=socket.signal(controller.signal).request("longOperation",data)// Cancel the request at any timecontroller.abort()// The promise will be rejected with "Request aborted"promise.catch((err)=>{if(errinstanceofRequestAbortedError){console.log("Request was cancelled by user")}})// The remote end will also be notified of the cancellationEvent handlers can access the AbortSignal via this.signal to implement
cooperative cancellation:
socket.on("longOperation",asyncfunction(data){// Do long running work, checking signal periodicallyfor(leti=0;i<10;i++){if(this.signal?.aborted){thrownewError("Operation was cancelled")}awaitdoSomeWork()}return"Operation completed"})You can use both timeout and cancellation together:
importWebSocketWrapper,{RequestTimeoutError,RequestAbortedError,}from"ws-wrapper"constcontroller=newAbortController()constpromise=socket.timeout(30000)// 30 second timeout.signal(controller.signal)// User cancellation.request("heavyComputation",data)// Handle different error typespromise.catch((err)=>{if(errinstanceofRequestTimeoutError){console.log("Request timed out after 30 seconds")}elseif(errinstanceofRequestAbortedError){console.log("Request was cancelled by user")}else{console.log("Request failed with other error:",err)}})Anonymous channels are request-scoped channels. A request handler can call
this.channel() to create a channel and return it, and the requestor's
request() Promise resolves to a full WebSocketChannel instead of a plain
value. This provides a scoped, two-way communication primitive for streaming,
pagination, and other multi-message patterns — all over a single WebSocket
connection.
Anonymous channels should be explicitly closed when done. Use chan.close() to
clean up locally; use chan.abort() to also notify the remote end (which closes
its anonymous channel). Calling emit or request on a closed channel throws
an error. If the remote end sends a cancellation for the channel, the local
channel is automatically closed and closeSignal.reason is set to the
reconstructed cancellation reason.
Server-side example:
socket.on("watchTemperature",function(){// Create an anonymous channelconstchan=this.channel()// Register whatever listeners the client will emit on the channelchan.on("stop",()=>chan.close())chan.on("start",()=>{// Start pushing temperature readings once the client is readyconsttimer=setInterval(()=>{try{// emit() / request() will throw if chan closeschan.emit("temp",getSensorReading())}catch(err){clearInterval(timer)}},1000)// Clean up when the channel is closedchan.closeSignal?.addEventListener("abort",()=>clearInterval(timer))})// Note: calling `chan.emit()` or `chan.request()` is not allowed here...// You have to return the channel first!returnchan// returning a channel sends it to the requestor})Client-side example:
// request() resolves to the anonymous channelconstchan=awaitsocket.request("watchTemperature")chan.on("temp",(val)=>console.log("Temperature:",val))chan.emit("start")// start emitting after "temp" event handler is registered// Stop the stream after 10 secondssetTimeout(()=>{chan.emit("stop")chan.close()},10000)Server-side example:
socket.on("openCalculator",function(){constchan=this.channel()chan.on("add",function(a,b){returna+b// return value is sent back as a response})chan.on("multiply",function(a,b){returna*b})chan.on("done",()=>chan.close())returnchan})Client-side example:
constcalc=awaitsocket.request("openCalculator")constsum=awaitcalc.request("add",3,4)// 7constproduct=awaitcalc.request("multiply",6,7)// 42calc.emit("done")// closes remote sidecalc.close()// closes my side *OR* calc.abort() closes both sidesAnonymous channels have a built-in [Symbol.asyncIterator] implementation,
enabling one-way streaming using for await...of. The handler drives the stream
by emitting "next" events with { value, done } payloads; the iterator emits
"start" on the first call to next() to signal readiness.
Server-side example:
socket.on("generateNumbers",function(){constchan=this.channel()returnchan.on("start",()=>{for(leti=1;i<=100;i++){chan.emit("next",{value: i,done: false})}chan.emit("next",{value: undefined,done: true})chan.close()// clean up server-side channel})})Client-side example:
constchan=awaitsocket.request("generateNumbers")forawait(constvalueofchan){console.log(value)// 1, 2, ..., 100}// Iterator completed; channel is still open, so we close itchan.close()iterableHandler lets you write a streaming handler as a plain JS generator
(sync or async) instead of wiring "start" / "next" events by hand. Return
any sync or async iterable and ws-wrapper handles the rest.
importWebSocketWrapper,{iterableHandler}from"ws-wrapper"// Sync generator — yields items one by onesocket.on("generateNumbers",iterableHandler(function*(){for(leti=1;i<=100;i++)yieldi}))// Async generator — works with async data sourcessocket.on("streamRows",iterableHandler(asyncfunction*(query){forawait(constrowofdb.query(query)){yieldrow}}))// Any iterable works — arrays, Sets, Maps, generators, ...socket.on("listUsers",iterableHandler(()=>activeUsers))The client side is unchanged — request() resolves to the anonymous channel and
for await...of consumes it:
constchan=awaitsocket.request("generateNumbers")forawait(constvalueofchan){console.log(value)// 1, 2, ..., 100}chan.close()If the requestor aborts the anonymous channel mid-stream, the generator stops on
the next iteration. yield always evaluates to undefined since the stream is
one-way.
When a requestor uses signal() with a request() that resolves to an
anonymous channel, the AbortSignal is inherited by the anonymous channel. If the
signal aborts after the channel is created, chan.abort() is called
automatically, sending a cancellation to the remote end and closing the channel
locally.
constcontroller=newAbortController()constchan=awaitsocket.signal(controller.signal).request("startStream")// The channel inherits the signal; aborting it aborts the channel.controller.abort()Note
timeout() is not inherited by the anonymous channel. To impose a time
limit on both the request and anonymous channel, pass an
AbortSignal.timeout() as the signal:
constchan=awaitsocket.signal(AbortSignal.timeout(30_000)).request("startStream")See API.md for the full API reference, including:
WebSocketWrapperconstructor and options- Channels (
socket.of()) - EventEmitter-like API (
on,once,emit, etc.) - Request / Response (
request,timeout,signal) - Async Iterator (one-way streaming) and
iterableHandler - Middleware
- Other methods, properties, and error classes
See PROTOCOL.md for the full wire protocol specification, including all message types (Event Dispatch, Request, Response, Request Cancellation, Anonymous Channel messages, and more).
ws-wrapper does not implement auto-reconnect functionality out of the box. For those who want it (almost everyone), I have written some sample code to show how easy it is to add.
How to implement auto-reconnect for ws-wrapper
If someone wants to make an npm package for the auto-reconnect feature, I'd be happy to list it here, but it will probably never be a core ws-wrapper feature.
Footnotes
Library protocol is not compatible with a bare WebSocket ↩↩2
Easy to implement ↩
ws-wrapper channels are persistent namespaces scoped to a single connection (similar to Socket.IO namespaces). ws-wrapper anonymous channels are request-scoped and suit streaming / iterator patterns – loosely analogous to Socket.IO rooms in that they scope a subset of communication. ↩