A lightweight, isomorphic library that brings named events, Promise-based requests, channels, and cancellation signals to native WebSockets — with first-class support for browsers, Node.js, and Go servers.
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, just like 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
- 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
The wire protocol is a thin JSON layer over the native WebSocket, keeping everything interoperable across JavaScript (browser or Node.js) and Go.
Socket.io is great, but it ships with the full 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).
Node.js / Browser
npm install ws-wrapper
or 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
WebSocketWrapper is an ES module, so it works in Node.js and in the browser if you use a bundler like Browserify, Webpack, or Parcel.js.
Check out the example-app for a sample chat application (recommended).
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.
// Use a bundler to make the next line of code "work" on the browser
import WebSocketWrapper from "ws-wrapper"
// Create a new socket
const socket = new WebSocketWrapper(new WebSocket("ws://" + location.hostname))
// Now use the WebSocketWrapper API... `socket.emit` for example
socket.emit("msg", "my_name", "This is a test message")
// See additional examples below...Use ws-server-wrapper to wrap the WebSocketServer (recommended). See the ws-server-wrapper README for more details.
If you don't want to use ws-server-wrapper, you can wrap the WebSocket once a new WebSocket connects like this:
import { WebSocketServer } from "ws"
import WebSocketWrapper from "ws-wrapper"
var wss = new WebSocketServer({ port: 3000 })
wss.on("connection", (socket) => {
socket = new WebSocketWrapper(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"
)
func main() {
wsServer := wrapper.NewServer()
// Register an event handler; return values are sent back as a response
wsServer.On("echo", func(s string) (string, error) {
return s, 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)
if err != 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.
No such libraries exist yet. :( Please create one, 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"
import WebSocketWrapper from "ws-wrapper"
var wss = new WebSocketServer({ port: 3000 })
var sockets = new Set()
wss.on("connection", (socket) => {
var socket = new WebSocketWrapper(socket)
sockets.add(socket)
socket.on("msg", function (from, msg) {
// `this` refers to the WebSocketWrapper instance
console.log(`Received message from ${from}: ${msg}`)
// Relay message to all clients
sockets.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 browser
import WebSocketWrapper from "ws-wrapper"
// Establish connection
var socket = new WebSocketWrapper(new WebSocket("ws://" + location.host))
// Add "msg" event handler
socket.on("msg", function (from, msg) {
console.log(`Received message from ${from}: ${msg}`)
})
// Emit "msg" event
socket.emit("msg", "my_name", "This is a test message")Note: This module uses JSON.stringify to encode data as JSON over the raw
WebSocket connection. This means that serializing circular references is not
supported out of the box.
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):
import fs from "node:fs"
import { WebSocketServer } from "ws"
import WebSocketWrapper from "ws-wrapper"
const wss = new WebSocketServer({ port: 3000 })
const sockets = new Set()
wss.on("connection", (socket) => {
socket = new WebSocketWrapper(socket)
sockets.add(socket)
socket.on("userCount", () => {
// Return value is sent back to the client
return sockets.size
})
socket.on("readFile", (path) => {
// We can return a Promise that eventually resolves
return new Promise((resolve, reject) => {
// TODO: `path` should be sanitized for security reasons
fs.readFile(path, (err, data) => {
// `err` or `data` are now sent back to the client
if (err) reject(err)
else resolve(data.toString("utf8"))
})
})
})
socket.on("disconnect", () => {
sockets.delete(socket)
})
})Client-side Example:
// Assuming WebSocketWrapper is somehow available to this scope...
const socket = new WebSocketWrapper(new WebSocket("ws://" + location.host))
var p = 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)
})Class: WebSocketWrapper
A WebSocketWrapper simply wraps around a WebSocket to give you well-deserved functionality. 😄
socket = new WebSocketWrapper(webSocketInstance[, options]);
Constructs a new WebSocketWrapper, and binds it to the native WebSocket instance.
webSocketInstance- the native WebSocket instanceoptionsdebug- set totrueto print debugging messages toconsole.logerrorToJSON- function to serialize Errors over the WebSocket. In Node.js, the default is to send only themessageproperty of the Error (for security reasons). Errors that occur on the browser include all properties.requestTimeout- maximum delay in ms. that the WebSocketWrapper will wait until rejecting the Promise of a pending request. Defaults tonull, which means that there will be no timeout. This option is recommended for servers because clients who do not fulfill pending requests can cause memory leaks. As of version 4, we send cancellation messages to the remote end for requests that time out.
Events
- Event: "open" / "connect"
event- The (worthless) event from the native WebSocket instance
- Event: "error"
event- The Error event from the native WebSocket instance
- Event: "message"
event- The Message event from the native WebSocket instancedata- The message data (same asevent.data)
- Event: "close" / "disconnect"
event- The Close event from the native WebSocket instancewasOpen-trueif the "open" event was fired on the native WebSocket instance before the "close" event was fired.
Note: The "special" events listed above are not sent over the WebSocket.
The EventEmitter-like API looks like this:
-
socket.on(eventName, listener)Adds thelistenerfunction to the end of the listeners array for the event namedeventName. When an event or request matching theeventNameis received by the WebSocket, thelisteneris called.Values returned by the
listenercallback are used to respond to requests (seesocket.request). If the return value of thelisteneris aPromise, the response to the request will be sent once the Promise is resolved or rejected; otherwise, the return value of thelisteneris sent back to the remote end immediately.If the inbound message is a simple event (see
socket.emit), the return value of thelisteneris ignored. It is also "safe" for thelistenerto return aPromiseeven if the inbound message is a "simple" event. If the returnedPromiseis rejected, an unhandled rejection will not occur; rather, the result of the Promise is just ignored.If the
listenerthrows an Error, this Error will propagate up the stack as expected, and if the inbound message was a request, the Error is sent back to the remote end as a response rejection. -
socket.once(eventName, listener)Adds a one timelistenerfunction for the event namedeventName. -
socket.removeListener(eventName, listener)Removes the specifiedlistenerfrom the listener array for the event namedeventName. -
socket.removeAllListeners([eventName])Removes all listeners, or those of the specifiedeventName. -
socket.eventNames()Returns an array listing the events for which the emitter has registered listeners. -
socket.listeners(eventName)Returns a copy of the array of listeners for the event namedeventName. -
socket.emit(eventName[, ...args])Sends an event down the WebSocket with the specifiedeventNamecalling all listeners foreventNameon the remote end, in the order they were registered, passing the supplied arguments to each. -
socket.request(eventName[, ...args])Sends a request down the WebSocket with the specifiedeventNameand returns a Promise that will resolve once the remote event listener responds.Note: While it is common design for only one event listener to exist on the remote end, all listeners for
eventNameon the remote end are called, in the order they were registered, passing the supplied arguments to each. Since Promises can only be resolved or rejected once, only the data from the first event listener is used to generate the response for this request.Note: If a request is sent, but there is no remote event listener to respond to the request, a response rejection is immediately sent back by the remote end.
-
socket.use(function fn(eventName, args, next) {...})Adds a middleware functionfnto receive all messages for the channel. TheeventNameindicates the name of the event or request, and theargsare the arguments to be passed to the respective event handler.next([err])should be called to continue processing to the next middleware function. Once all middleware have processed the event and callednext, the event is then processed by the event handler for theeventName. Ifnext(err)is called with an Error, the event will not be handled by subsequent middleware or registered event handlers, and if it's a request, a response rejection is sent back to the remote end. -
socket.timeout(tempTimeoutInMs)Temporarily sets therequestTimeouttotempTimeoutInMsfor the next request only. This returnssocketto allow chaining. Typical usage:// The next request will be rejected if there is no response for 5 secs. let promise = socket.timeout(5 * 1000).request("readFile", "/etc/issue")
-
socket.signal(abortSignal)Temporarily sets theAbortSignalfor the next request only. This allows cancellation of in-flight requests. This returnssocketto allow chaining. Can be combined withtimeout(). Typical usage:const controller = new AbortController() // The next request can be cancelled using the AbortController let promise = socket.signal(controller.signal).request("longOperation", data) // Later, we can cancel the request. In this case, our Promise will be // immediately rejected with RequestAbortedError, and the remote end will // receive a cancellation message. controller.abort() // Combined with timeout let promise2 = socket .timeout(10000) .signal(controller.signal) .request("heavyTask", data)
The above EventEmitter functions like on and once are chainable (as
appropriate).
Channel API:
socket.of(channelName)Returns the channel with the specifiedchannelName. Every channel has the same EventEmitter-like API as described above for sending and handling channel-specific events and requests. A channel also has a read-onlynameproperty.
Other methods and properties:
By default, the WebSocketWrapper provides a queue for data to be sent. Once the WebSocket is open, this queue is flushed until the connection is lost. The following methods allow one to re-bind a new WebSocket or clear the send queue. This is useful for reconnecting or connecting to a different server.
socket.abort()Clears the send queue for this WebSocketWrapper and rejects all Promises for pending requests.socket.bind(nativeWebSocket)Binds this WebSocketWrapper to a new WebSocket. This can be useful when socket reconnection logic needs to be implemented. Instead of creating a new WebSocketWrapper each time a WebSocket is disconnected, one can simply bind a new WebSocket to the WebSocketWrapper. In this way, data queued to be sent while the connection was dead will be sent over the new WebSocket passed to thebindfunction.socket.isConnecting- checks the native WebSocketreadyStateand istrueif and only if the state is CONNECTING.socket.isConnected- checks the native WebSocketreadyStateistrueif and only if the state is OPEN.socket.send(data)If connected, calls the native WebSocket'ssendmethod; otherwise, the data string is added to the WebSocketWrapper's send queue.socket.disconnect()Closes the native WebSocketsocket.set(key, value)Saves user data specific to this WebSocketWrappersocket.get(key)Retrieves user data. Seesocket.set(key, value)above.
WebSocketWrapper.MAX_SEND_QUEUE_SIZE The maximum number of items allowed in
the send queue. If a user tries to send more messages than this number while a
WebSocket is not connected, errors will be thrown. Defaults to 10; changes
affect all WebSocketWrapper instances.
ws-wrapper exports custom error classes that extend the standard Error class
to provide more specific error handling for different failure scenarios.
import WebSocketWrapper, { RequestTimeoutError } from "ws-wrapper"
socket
.timeout(5000)
.request("slowOperation")
.catch((err) => {
if (err instanceof RequestTimeoutError) {
console.log("Request timed out after 5 seconds")
}
})Properties:
name"RequestTimeoutError"message"Request timed out"
This error is thrown when a request exceeds the configured timeout period. The
timeout can be set globally via the requestTimeout constructor option or
per-request using the .timeout() method.
import WebSocketWrapper, { RequestAbortedError } from "ws-wrapper"
const controller = new AbortController()
socket
.signal(controller.signal)
.request("longOperation")
.catch((err) => {
if (err instanceof RequestAbortedError) {
console.log("Request was cancelled:", err.reason)
}
})
// Cancel the request
controller.abort("User cancelled")Properties:
name"RequestAbortedError"message"Request aborted"reasonThe reason passed toAbortController.abort()(if any)
This error is thrown when a request is cancelled via an AbortSignal. The
optional reason property contains any cancellation reason that was provided
when calling abort().
Error classes are available in multiple ways:
// Named imports
import { RequestTimeoutError, RequestAbortedError } from "ws-wrapper"
// Via the main class
import WebSocketWrapper from "ws-wrapper"
const TimeoutError = WebSocketWrapper.RequestTimeoutError
const AbortedError = WebSocketWrapper.RequestAbortedErrorAll data passed over the native WebSocket should be valid JSON, but this is not a hard requirement. ws-wrapper will try to parse a JSON string and determine the message type based on the properties in the parsed Object.
The following message types are defined by ws-wrapper:
-
Event Dispatch - Identified by an Object with
akey but noikey. The channel name is optional.{ "c": "channel_name", "a": ["event_name", "first_arg", "second_arg", "last_arg"] }
The client or server can send events. Events are nothing more than an event name and some data, passed as arguments to the event handler.
-
Request - Identified by an Object with
aandikeys whereirefers to the unique request identifier. The channel name is optional.{ "i": 123, "c": "channel_name", "a": ["event_name", "first_arg", "second_arg", "last_arg"] }
The client or server can send a Request, which is essentially an Event that needs some sort of server Response.
-
Response (Resolution) - Identified by an Object with
ianddkeys whereiis the request identifier anddis the response data.{ "i": 123, "d": {"resolved": "data", "hello": "world"} }
-
Response (Rejection) - Identified by an Object with
iandekeys whereiis the request identifier andeis the rejected value. If_is set,eis a serialized Error object (with at least amessagekey) that is reconstructed as anErrorinstance upon receipt.nullandundefinedrejection values are replaced with a defaultErrorobject. All other values fore(strings, numbers, plain objects, etc.) are passed through as-is.// Error instance (e.g. throw new Error("oops")) { "i": 123, "e": {"message": "oops"}, "_": 1 } // Any other thrown value (e.g. throw "oops" or throw {code: 42}) { "i": 123, "e": "oops" }
-
Request Cancellation - Identified by an Object with
iandxkeys whereiis the request identifier to cancel andxis the cancellation reason. The same rules aseapply: if_is set,xis a serialized Error object, and if the reason is nullish, a default Error is sent. Introduced in ws-wrapper v4.// Default (no reason provided) { "i": 123, "x": {"message": "Request aborted"}, "_": 1 } // String reason { "i": 123, "x": "user cancelled" }
When a request is cancelled using an
AbortSignal, a cancellation message is sent to the remote end. TheAbortSignal.reasonis forwarded asx(with_: 1when it is an Error instance).nullandundefinedreasons are replaced with a defaultRequestAbortedError(same rule as fore). All other reason values are sent exactly as-is. The remote end can use this information to stop processing the request and clean up any resources. Event handlers on the remote end can access theAbortSignalviathis.signalto implement cooperative cancellation.
If the message received by the WebSocket is not valid JSON or if the parsed
Object does not match one of the above message types, then the message is simply
ignored by ws-wrapper. Also if the JSON message contains a ws-wrapper property
with the value false, the message will be ignored. This allows other libraries
to use the same WebSocket and send messages that will not be processed by
ws-wrapper.
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.
const controller = new AbortController()
// Send a request that can be cancelled
const promise = socket.signal(controller.signal).request("longOperation", data)
// Cancel the request at any time
controller.abort()
// The promise will be rejected with "Request aborted"
promise.catch((err) => {
if (err instanceof RequestAbortedError) {
console.log("Request was cancelled by user")
}
})Event handlers can access the AbortSignal via this.signal to implement
cooperative cancellation:
socket.on("longOperation", async function (data) {
// Check if already cancelled
if (this.signal?.aborted) {
throw new Error("Operation was cancelled")
}
// Listen for cancellation during processing
const onAbort = () => {
// Perhaps do something here on abort...
}
this.signal?.addEventListener("abort", onAbort)
try {
// Do long running work, checking signal periodically
for (let i = 0; i < 10; i++) {
if (this.signal?.aborted) {
throw new Error("Operation was cancelled")
}
await doSomeWork()
}
return "Operation completed"
} finally {
this.signal?.removeEventListener("abort", onAbort)
}
})You can use both timeout and cancellation together:
import WebSocketWrapper, {
RequestTimeoutError,
RequestAbortedError,
} from "ws-wrapper"
const controller = new AbortController()
const promise = socket
.timeout(30000) // 30 second timeout
.signal(controller.signal) // User cancellation
.request("heavyComputation", data)
// Handle different error types
promise.catch((err) => {
if (err instanceof RequestTimeoutError) {
console.log("Request timed out after 30 seconds")
} else if (err instanceof RequestAbortedError) {
console.log("Request was cancelled by user")
} else {
console.log("Request failed with other error:", err)
}
})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.