UNPKG

ws-wrapper

Version:

Lightweight WebSocket wrapper lib with socket.io-like event handling, requests, and channels

602 lines (490 loc) 20.7 kB
# ws-wrapper A lightweight, isomorphic library that brings named events, Promise-based requests, channels, and more to native [WebSockets](https://en.wikipedia.org/wiki/WebSocket) — with first-class support for **web browsers**, **Node.js**, and **Go**. ## What? 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](https://socket.io/docs/)) - **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 `AbortSignal` API, 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). ## Why? [Socket.IO](https://socket.io/docs/) is great, but it lacks a few features and ships with the heavier [engine.io](https://github.com/socketio/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). ## Comparison | Feature | ws-wrapper | [Socket.IO](https://socket.io/) | [ws](https://github.com/websockets/ws) | [SockJS](https://github.com/sockjs/sockjs-client) | | --------------------------------------- | :--------: | :-----------------------------: | :------------------------------------: | :-----------------------------------------------: | | 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 | [^1]: Library protocol is not compatible with a bare WebSocket [^2]: Easy to implement [^3]: ws-wrapper **channels** are persistent namespaces scoped to a single connection (similar to Socket.IO [namespaces](https://socket.io/docs/v4/namespaces/)). ws-wrapper **anonymous channels** are request-scoped and suit streaming / iterator patterns – loosely analogous to Socket.IO [rooms](https://socket.io/docs/v4/rooms/) in that they scope a subset of communication. ## Install **Node.js / Browser** ``` npm install ws-wrapper ``` or for Node.js servers, use the recommended [ws-server-wrapper](https://github.com/bminer/ws-server-wrapper) library: ``` npm install ws-server-wrapper ``` **Go server** (use with [ws-server-wrapper-go](https://github.com/bminer/ws-server-wrapper-go)) ``` go get github.com/bminer/ws-server-wrapper-go ``` ## Usage 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](https://github.com/bminer/ws-wrapper/tree/master/example-app) for a sample chat application (recommended). #### Client-side ```javascript // 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... ``` 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](https://babeljs.io/). #### Server-side (Node.js) We recommend using [ws-server-wrapper](https://github.com/bminer/ws-server-wrapper) to wrap the WebSocketServer. See the [ws-server-wrapper README](https://github.com/bminer/ws-server-wrapper/blob/master/README.md) 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: ```javascript import { WebSocketServer } from "ws" import WebSocketWrapper from "ws-wrapper" var wss = new WebSocketServer({ port: 3000 }) wss.on("connection", (socket) => { socket = new WebSocketWrapper(socket) // ... }) ``` #### Server-side (Go) Use [ws-server-wrapper-go](https://github.com/bminer/ws-server-wrapper-go) to wrap your favorite WebSocket library. The example below uses the [coder/websocket](https://github.com/coder/websocket) adapter: ```go 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](https://github.com/bminer/ws-server-wrapper-go) repository for a complete example and other adapter options. #### Other servers Please implement ws-wrapper in your favorite language, and let me know about it! I'll give you beer! ## Event Handling 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_): ```javascript 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: ```javascript // 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: 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. ## Channels 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")`. ## Request / Response Event handlers can return values or [Promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) 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_): ```javascript 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: ```javascript // 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) }) ``` ### Request Timeout Call `.timeout(ms)` directly before `.request(...)` like this: ```js const promise = socket.timeout(5 * 1000).request("readFile", "/etc/issue") ``` The `timeout` function affects the next call of `request`. ### Request Cancellation 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. ```javascript // Send a request that can be cancelled const controller = new AbortController() 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") } }) // The remote end will also be notified of the cancellation ``` Event handlers can access the `AbortSignal` via `this.signal` to implement cooperative cancellation: ```javascript socket.on("longOperation", async function (data) { // 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" }) ``` ### Combining with Timeout You can use both timeout and cancellation together: ```javascript 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) } }) ``` ## Anonymous Channels **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. ### One-way streaming (server pushes values to client) Server-side example: ```javascript socket.on("watchTemperature", function () { // Create an anonymous channel const chan = this.channel() // Register whatever listeners the client will emit on the channel chan.on("stop", () => chan.close()) chan.on("start", () => { // Start pushing temperature readings once the client is ready const timer = setInterval(() => { try { // emit() / request() will throw if chan closes chan.emit("temp", getSensorReading()) } catch (err) { clearInterval(timer) } }, 1000) // Clean up when the channel is closed chan.closeSignal?.addEventListener("abort", () => clearInterval(timer)) }) // Note: calling `chan.emit()` or `chan.request()` is not allowed here... // You have to return the channel first! return chan // returning a channel sends it to the requestor }) ``` Client-side example: ```javascript // request() resolves to the anonymous channel const chan = await socket.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 seconds setTimeout(() => { chan.emit("stop") chan.close() }, 10000) ``` ### Two-way communication (sub-requests on the channel) Server-side example: ```javascript socket.on("openCalculator", function () { const chan = this.channel() chan.on("add", function (a, b) { return a + b // return value is sent back as a response }) chan.on("multiply", function (a, b) { return a * b }) chan.on("done", () => chan.close()) return chan }) ``` Client-side example: ```javascript const calc = await socket.request("openCalculator") const sum = await calc.request("add", 3, 4) // 7 const product = await calc.request("multiply", 6, 7) // 42 calc.emit("done") // closes remote side calc.close() // closes my side *OR* calc.abort() closes both sides ``` ### Async iterator Anonymous 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: ```javascript socket.on("generateNumbers", function () { const chan = this.channel() return chan.on("start", () => { for (let i = 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: ```javascript const chan = await socket.request("generateNumbers") for await (const value of chan) { console.log(value) // 1, 2, ..., 100 } // Iterator completed; channel is still open, so we close it chan.close() ``` ### `iterableHandler` — generators as stream handlers `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. ```javascript import WebSocketWrapper, { iterableHandler } from "ws-wrapper" // Sync generator — yields items one by one socket.on( "generateNumbers", iterableHandler(function* () { for (let i = 1; i <= 100; i++) yield i }) ) // Async generator — works with async data sources socket.on( "streamRows", iterableHandler(async function* (query) { for await (const row of db.query(query)) { yield row } }) ) // 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: ```javascript const chan = await socket.request("generateNumbers") for await (const value of chan) { 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. ### Signal inheritance 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. ```javascript const controller = new AbortController() const chan = await socket.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: > > ```javascript > const chan = await socket > .signal(AbortSignal.timeout(30_000)) > .request("startStream") > ``` ## API See [API.md](API.md) for the full API reference, including: - `WebSocketWrapper` constructor 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 ## Protocol See [PROTOCOL.md](PROTOCOL.md) for the full wire protocol specification, including all message types (Event Dispatch, Request, Response, Request Cancellation, Anonymous Channel messages, and more). ## Auto-Reconnect 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](https://github.com/bminer/ws-wrapper/wiki/Client-side-Auto-Reconnect) 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.