@ayonli/jsext
Version:
A JavaScript extension package for building strong and modern applications.
440 lines (436 loc) • 16.6 kB
JavaScript
;
var async = require('./async.js');
var bytes = require('./bytes.js');
var event = require('./event.js');
var runtime = require('./runtime.js');
var ws_base = require('./ws/base.js');
/**
* This module provides a unified WebSocket server interface for Node.js, Deno,
* Bun and Cloudflare Workers. This module is based on the `EventTarget`
* interface and conforms the web standard.
*
* **IMPORTANT**: The {@link WebSocketConnection} interface is an abstraction of
* the WebSocket on the server side, it's design is not consistent with the
* {@link WebSocket} API in the browser. For example, when receiving binary data,
* the `data` property is always a `Uint8Array` object, which is different from
* the `Blob` object (or `ArrayBuffer`) in the browser. In the future, we may
* provide a more consistent API, be aware of this when using this module.
* @module
* @experimental
*/
var _a, _b, _c, _d;
const _errored = Symbol.for("errored");
const _handler = Symbol.for("handler");
const _clients = Symbol.for("clients");
const _httpServer = Symbol.for("httpServer");
const _wsServer = Symbol.for("wsServer");
const _connTasks = Symbol.for("connTasks");
/**
* A unified WebSocket server interface for Node.js, Deno, Bun and Cloudflare
* Workers.
*
* There are two ways to handle WebSocket connections:
*
* 1. By passing a listener function to the constructor, handle all connections
* in a central place.
* 2. By using the `socket` object returned from the `upgrade` method to handle
* each connection in a more flexible way. However, at this stage, the
* connection may not be ready yet, and we need to listen the `open` event
* or wait for the `ready` promise (deprecated) to resolve before we can
* start sending messages.
*
* The `socket` object is an async iterable object, which can be used in the
* `for await...of` loop to read messages with backpressure support.
*
* @example
* ```ts
* // centralized connection handler
* import { WebSocketServer } from "@ayonli/jsext/ws";
*
* const wsServer = new WebSocketServer(socket => {
* console.log("WebSocket connection established.");
*
* socket.addEventListener("message", (event) => {
* socket.send("received: " + event.data);
* });
*
* socket.addEventListener("error", (event) => {
* console.error("WebSocket connection error:", event.error);
* });
*
* socket.addEventListener("close", (event) => {
* console.log(`WebSocket connection closed, reason: ${event.reason}, code: ${event.code}`);
* });
* });
*
* // Node.js
* import * as http from "node:http";
* const httpServer = http.createServer(req => {
* wsServer.upgrade(req);
* });
* httpServer.listen(3000);
*
* // Node.js (withWeb)
* import { withWeb } from "@ayonli/jsext/http";
* const httpServer2 = http.createServer(withWeb(req => {
* const { response } = wsServer.upgrade(req);
* return response;
* }));
* httpServer2.listen(3001);
*
* // Bun
* const bunServer = Bun.serve({
* fetch(req) {
* const { response } = wsServer.upgrade(req);
* return response;
* },
* websocket: wsServer.bunListener,
* });
* wsServer.bunBind(bunServer);
*
* // Deno
* Deno.serve(req => {
* const { response } = wsServer.upgrade(req);
* return response;
* });
*
* // Cloudflare Workers
* export default {
* fetch(req) {
* const { response } = wsServer.upgrade(req);
* return response;
* },
* };
* ```
*
* @example
* ```ts
* // per-request connection handler (Deno example)
* import { WebSocketServer } from "@ayonli/jsext/ws";
*
* const wsServer = new WebSocketServer();
*
* Deno.serve(req => {
* const { socket, response } = wsServer.upgrade(req);
*
* socket.addEventListener("open", () => {
* console.log("WebSocket connection established.");
* });
*
* socket.addEventListener("message", (event) => {
* socket.send("received: " + event.data);
* });
*
* socket.addEventListener("error", (event) => {
* console.error("WebSocket connection error:", event.error);
* });
*
* socket.addEventListener("close", (event) => {
* console.log(`WebSocket connection closed, reason: ${event.reason}, code: ${event.code}`);
* });
*
* // The response should be returned immediately, otherwise the web socket
* // will not be ready.
* return response;
* });
* ```
*
* @example
* ```ts
* // async iterable
* const wsServer = new WebSocketServer(async socket => {
* console.log("WebSocket connection established.");
*
* try {
* for await (const message of socket) {
* socket.send("received: " + message);
* }
* } catch (error) {
* console.error("WebSocket connection error:", error);
* }
*
* console.log("WebSocket connection closed");
* });
*
* Deno.serve(req => {
* const { response } = wsServer.upgrade(req);
* return response;
* });
* ```
*/
class WebSocketServer {
constructor(...args) {
var _e, _f, _g;
this[_a] = new Map();
this[_b] = undefined;
this[_c] = null;
this[_d] = new Map();
if (args.length === 2) {
this.idleTimeout = ((_e = args[0]) === null || _e === void 0 ? void 0 : _e.idleTimeout) || 30;
this.perMessageDeflate = (_g = (_f = args[0]) === null || _f === void 0 ? void 0 : _f.perMessageDeflate) !== null && _g !== void 0 ? _g : false;
this[_handler] = args[1];
}
else {
this.idleTimeout = 30;
this.perMessageDeflate = false;
this[_handler] = args[0];
}
}
upgrade(request) {
const upgradeHeader = "socket" in request
? request.headers["upgrade"]
: request.headers.get("Upgrade");
if (!upgradeHeader || upgradeHeader !== "websocket") {
throw new TypeError("Expected Upgrade: websocket");
}
const handler = this[_handler];
const clients = this[_clients];
const { identity } = runtime.default();
if (identity === "deno") {
if ("socket" in request) {
throw new TypeError("Node.js support is not implemented outside Node.js runtime.");
}
const { socket: ws, response } = Deno.upgradeWebSocket(request, {
idleTimeout: this.idleTimeout,
});
const socket = new ws_base.WebSocketConnection(new Promise((resolve) => {
ws.binaryType = "arraybuffer";
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
socket.dispatchEvent(new MessageEvent("message", {
data: ev.data,
}));
}
else {
socket.dispatchEvent(new MessageEvent("message", {
data: new Uint8Array(ev.data),
}));
}
};
ws.onclose = (ev) => {
if (!ev.wasClean) {
socket.dispatchEvent(event.createErrorEvent("error", {
error: new Error(`WebSocket connection closed: ${ev.reason} (${ev.code})`),
}));
}
clients.delete(request);
socket.dispatchEvent(event.createCloseEvent("close", {
code: ev.code,
reason: ev.reason,
wasClean: ev.wasClean,
}));
};
if (ws.readyState === 1) {
resolve(ws);
}
else {
ws.onopen = () => {
resolve(ws);
};
}
}));
socket.ready.then(() => {
clients.set(request, socket);
handler === null || handler === void 0 ? void 0 : handler.call(this, socket);
socket.dispatchEvent(new Event("open"));
});
return { socket, response };
}
else if (identity === "bun") {
if ("socket" in request) {
throw new TypeError("Node.js support is not implemented outside Node.js runtime.");
}
const server = this[_httpServer];
if (!server) {
throw new Error("WebSocket server is not bound to a Bun server instance.");
}
const task = async.asyncTask();
this[_connTasks].set(request, task);
const ok = server.upgrade(request, { data: { request } });
if (!ok) {
throw new Error("Failed to upgrade to WebSocket");
}
const socket = new ws_base.WebSocketConnection(task);
socket.ready.then(() => {
clients.set(request, socket);
handler === null || handler === void 0 ? void 0 : handler.call(this, socket);
socket.dispatchEvent(new Event("open"));
});
return {
socket,
response: new Response(null, {
status: 101,
statusText: "Switching Protocols",
headers: new Headers({
"Upgrade": "websocket",
"Connection": "Upgrade",
}),
}),
};
}
else if (identity === "node") {
const isNodeRequest = "socket" in request;
if (!isNodeRequest && Reflect.has(request, Symbol.for("incomingMessage"))) {
request = Reflect.get(request, Symbol.for("incomingMessage"));
}
if (!("socket" in request)) {
throw new TypeError("Expected an instance of http.IncomingMessage");
}
const { socket } = request;
const upgradeHeader = request.headers.upgrade;
if (!upgradeHeader || upgradeHeader !== "websocket") {
throw new TypeError("Expected Upgrade: websocket");
}
const handler = this[_handler];
const clients = this[_clients];
if (!this[_wsServer]) {
this[_wsServer] = import('ws').then(({ WebSocketServer: WsServer }) => {
return new WsServer({
noServer: true,
perMessageDeflate: this.perMessageDeflate,
});
});
}
const task = this[_wsServer].then(wsServer => new Promise((resolve) => {
wsServer.handleUpgrade(request, socket, Buffer.alloc(0), (ws) => {
ws.on("message", (data, isBinary) => {
data = Array.isArray(data) ? bytes.concat(...data) : data;
let event;
if (typeof data === "string") {
event = new MessageEvent("message", { data });
}
else {
if (isBinary) {
event = new MessageEvent("message", {
data: new Uint8Array(data),
});
}
else {
const bytes$1 = data instanceof ArrayBuffer
? new Uint8Array(data)
: data;
event = new MessageEvent("message", {
data: bytes.text(bytes$1),
});
}
}
client.dispatchEvent(event);
});
ws.on("error", error => {
Object.assign(ws, { [_errored]: true });
client.dispatchEvent(event.createErrorEvent("error", { error }));
});
ws.on("close", (code, reason) => {
var _e;
clients.delete(request);
client.dispatchEvent(event.createCloseEvent("close", {
code,
reason: (_e = reason === null || reason === void 0 ? void 0 : reason.toString("utf8")) !== null && _e !== void 0 ? _e : "",
wasClean: Reflect.get(ws, _errored) !== false,
}));
});
resolve(ws);
});
}));
const client = new ws_base.WebSocketConnection(task);
client.ready.then(() => {
clients.set(request, client);
handler === null || handler === void 0 ? void 0 : handler.call(this, client);
client.dispatchEvent(new Event("open"));
});
if (!isNodeRequest && typeof Response === "function") {
const response = new Response(null, {
status: 200,
statusText: "Switching Protocols",
headers: new Headers({
"Upgrade": "websocket",
"Connection": "Upgrade",
}),
});
// HACK: Node.js currently does not support setting the
// status code to outside the range of 200 to 599. This
// is a workaround to set the status code to 101.
Object.defineProperty(response, "status", {
configurable: true,
value: 101,
});
return { socket: client, response };
}
else {
return { socket: client };
}
}
else {
throw new TypeError("Unsupported runtime");
}
}
/**
* Used in Bun, to bind the WebSocket server to the Bun server instance.
*/
bunBind(server) {
this[_httpServer] = server;
}
/**
* A WebSocket listener for `Bun.serve()`.
*/
get bunListener() {
const clients = this[_clients];
const connTasks = this[_connTasks];
return {
idleTimeout: this.idleTimeout,
perMessageDeflate: this.perMessageDeflate,
open: async (ws) => {
const { request } = ws.data;
const task = connTasks.get(request);
if (task) {
connTasks.delete(request);
task.resolve(ws);
}
},
message: (ws, msg) => {
const { request } = ws.data;
const client = clients.get(request);
if (client) {
if (typeof msg === "string") {
client.dispatchEvent(new MessageEvent("message", {
data: msg,
}));
}
else {
client.dispatchEvent(new MessageEvent("message", {
data: new Uint8Array(msg),
}));
}
}
},
error: (ws, error) => {
Object.assign(ws, { [_errored]: true });
const { request } = ws.data;
const client = clients.get(request);
client && client.dispatchEvent(event.createErrorEvent("error", { error }));
},
close: (ws, code, reason) => {
const { request } = ws.data;
const client = clients.get(request);
if (client) {
clients.delete(request);
client.dispatchEvent(event.createCloseEvent("close", {
code,
reason,
wasClean: Reflect.get(ws, _errored) !== true,
}));
}
},
};
}
/**
* An iterator that yields all connected WebSocket clients, can be used to
* broadcast messages to all clients.
*/
get clients() {
return this[_clients].values();
}
}
_a = _clients, _b = _httpServer, _c = _wsServer, _d = _connTasks;
exports.WebSocketServer = WebSocketServer;
//# sourceMappingURL=ws.js.map