diff options
| author | 2023-05-16 12:48:17 -0300 | |
|---|---|---|
| committer | 2023-05-16 08:48:17 -0700 | |
| commit | fe74c948cd3691295e2a7a8c8f6fa4229583c9ba (patch) | |
| tree | 46f6d30adc09141e8ed279ce5c3d14388b6da2d2 | |
| parent | 9c85483a8199f67feb4bebcb88fcc1bed5687916 (diff) | |
| download | bun-fe74c948cd3691295e2a7a8c8f6fa4229583c9ba.tar.gz bun-fe74c948cd3691295e2a7a8c8f6fa4229583c9ba.tar.zst bun-fe74c948cd3691295e2a7a8c8f6fa4229583c9ba.zip | |
feat(WebSocketServer) WebSocketServer wrapper + socket.io initial support (#2880)
* WebSocketServer wrapper + socket.io initial support
* fix up backpressure
* fix up backpressure
* fix http address
* add socket.io tests
* add closing tests
* add connection state recovery tests for socket.io
* add handshake test
* add middeware tests for socket.io
* added socket.io socket middleware tests
* add more socket.io test comment/skip hang tests
* add pending package for tests
* add server attachment servers for socket.io
* add utility-methods tests for socket.io
* rename
* rename
* add messaging-many socket.io tests
* add namespaces tests to socket.io
* skip some tests
* fmt
* add packages to general package.json
22 files changed, 6100 insertions, 14 deletions
@@ -77,6 +77,7 @@ ZIG ?= $(shell which zig 2>/dev/null || echo -e "error: Missing zig. Please make # so if that's resolved, it won't build for C++ REAL_CC = $(shell which clang-15 2>/dev/null || which clang 2>/dev/null) REAL_CXX = $(shell which clang++-15 2>/dev/null || which clang++ 2>/dev/null) +CLANG_FORMAT = $(shell which clang-format-15 2>/dev/null || which clang-format 2>/dev/null) CC = $(REAL_CC) CXX = $(REAL_CXX) @@ -557,11 +558,11 @@ builtins: ## to generate builtins $(PYTHON) $(realpath $(WEBKIT_DIR)/Source/JavaScriptCore/Scripts/generate-js-builtins.py) -i $(realpath src)/bun.js/builtins/js -o $(realpath src)/bun.js/builtins/cpp --framework WebCore --force $(PYTHON) $(realpath $(WEBKIT_DIR)/Source/JavaScriptCore/Scripts/generate-js-builtins.py) -i $(realpath src)/bun.js/builtins/js -o $(realpath src)/bun.js/builtins/cpp --framework WebCore --wrappers-only rm -rf /tmp/1.h src/bun.js/builtins/cpp/WebCoreJSBuiltinInternals.h.1 - echo -e '// clang-format off\nnamespace Zig { class GlobalObject; }\n#include "root.h"\n' >> /tmp/1.h + echo -e '// $(CLANG_FORMAT) off\nnamespace Zig { class GlobalObject; }\n#include "root.h"\n' >> /tmp/1.h cat /tmp/1.h src/bun.js/builtins/cpp/WebCoreJSBuiltinInternals.h > src/bun.js/builtins/cpp/WebCoreJSBuiltinInternals.h.1 mv src/bun.js/builtins/cpp/WebCoreJSBuiltinInternals.h.1 src/bun.js/builtins/cpp/WebCoreJSBuiltinInternals.h rm -rf /tmp/1.h src/bun.js/builtins/cpp/WebCoreJSBuiltinInternals.h.1 - echo -e '// clang-format off\nnamespace Zig { class GlobalObject; }\n#include "root.h"\n' >> /tmp/1.h + echo -e '// $(CLANG_FORMAT) off\nnamespace Zig { class GlobalObject; }\n#include "root.h"\n' >> /tmp/1.h cat /tmp/1.h src/bun.js/builtins/cpp/WebCoreJSBuiltinInternals.cpp > src/bun.js/builtins/cpp/WebCoreJSBuiltinInternals.cpp.1 mv src/bun.js/builtins/cpp/WebCoreJSBuiltinInternals.cpp.1 src/bun.js/builtins/cpp/WebCoreJSBuiltinInternals.cpp $(SED) -i -e 's/class JSDOMGlobalObject/using JSDOMGlobalObject = Zig::GlobalObject/' src/bun.js/builtins/cpp/WebCoreJSBuiltinInternals.h @@ -797,7 +798,7 @@ release-safe: prerelease release-safe-only .PHONY: fmt-cpp fmt-cpp: - cd src/bun.js/bindings && clang-format *.cpp *.h -i + cd src/bun.js/bindings && $(CLANG_FORMAT) *.cpp *.h -i .PHONY: fmt-zig fmt-zig: @@ -923,7 +924,6 @@ jsc-bindings: headers bindings clone-submodules: git -c submodule."src/bun.js/WebKit".update=none submodule update --init --recursive --depth=1 --progress -CLANG_FORMAT := $(shell command -v clang-format 2> /dev/null) .PHONY: headers headers: @@ -1447,11 +1447,11 @@ wasm-return1: generate-classes: bun src/bun.js/scripts/generate-classes.ts $(ZIG) fmt src/bun.js/bindings/generated_classes.zig - clang-format -i src/bun.js/bindings/ZigGeneratedClasses.h src/bun.js/bindings/ZigGeneratedClasses.cpp + $(CLANG_FORMAT) -i src/bun.js/bindings/ZigGeneratedClasses.h src/bun.js/bindings/ZigGeneratedClasses.cpp generate-sink: bun src/bun.js/scripts/generate-jssink.js - clang-format -i src/bun.js/bindings/JSSink.cpp src/bun.js/bindings/JSSink.h + $(CLANG_FORMAT) -i src/bun.js/bindings/JSSink.cpp src/bun.js/bindings/JSSink.h $(WEBKIT_DIR)/Source/JavaScriptCore/create_hash_table src/bun.js/bindings/JSSink.cpp > src/bun.js/bindings/JSSinkLookupTable.h $(SED) -i -e 's/#include "Lookup.h"//' src/bun.js/bindings/JSSinkLookupTable.h $(SED) -i -e 's/namespace JSC {//' src/bun.js/bindings/JSSinkLookupTable.h diff --git a/bench/socketio/client.js b/bench/socketio/client.js new file mode 100644 index 000000000..066a06483 --- /dev/null +++ b/bench/socketio/client.js @@ -0,0 +1,61 @@ +const { io } = require("socket.io-client"); +const port = process.env.PORT || 3000; + +const URL = `ws://localhost:${port}`; +const MAX_CLIENTS = 250; +const BATCHSIZE = MAX_CLIENTS / 10; +const BATCH_INTERVAL_IN_MS = 1000; +const EMIT_INTERVAL_IN_MS = 50; + +let clientCount = 0; +let lastReport = new Date().getTime(); +let packetsSinceLastReport = 0; + +const clients = []; +const createClient = () => { + const socket = io(URL); + clients.push(socket); + + socket.on("server to client event", () => { + packetsSinceLastReport++; + }); + + socket.on("disconnect", reason => { + console.log(`disconnect due to ${reason}`); + }); +}; + +let emitInterval = null; + +const createClients = () => { + for (let i = 0; i < BATCHSIZE; i++) { + createClient(); + clientCount++; + } + + if (clientCount < MAX_CLIENTS) { + setTimeout(createClients, BATCH_INTERVAL_IN_MS); + } + if (!emitInterval) { + emitInterval = setInterval(() => { + clients.forEach(socket => { + socket.emit("client to server event", "hello world"); + }); + }, EMIT_INTERVAL_IN_MS); + } +}; + +createClients(); + +const printReport = () => { + const now = new Date().getTime(); + const durationSinceLastReport = (now - lastReport) / 1000; + const packetsPerSeconds = (packetsSinceLastReport / durationSinceLastReport).toFixed(2); + + console.log(`client count: ${clientCount} ; average packets received per second: ${packetsPerSeconds}`); + + packetsSinceLastReport = 0; + lastReport = now; +}; + +setInterval(printReport, 1000); diff --git a/bench/socketio/server.js b/bench/socketio/server.js new file mode 100644 index 000000000..21252f192 --- /dev/null +++ b/bench/socketio/server.js @@ -0,0 +1,13 @@ +const http = require("http").createServer(); + +const io = require("socket.io")(http); +const port = process.env.PORT || 3000; +io.on("connection", socket => { + socket.on("client to server event", msg => { + io.emit("server to client event", msg); + }); +}); + +http.listen(port, () => { + console.log(`Socket.IO server running at http://localhost:${port}/`); +}); diff --git a/src/bun.js/http.exports.js b/src/bun.js/http.exports.js index 8176c1260..42a583dd6 100644 --- a/src/bun.js/http.exports.js +++ b/src/bun.js/http.exports.js @@ -1,4 +1,5 @@ const { EventEmitter } = import.meta.require("node:events"); +const { isIPv6 } = import.meta.require("node:net"); const { Readable, Writable, Duplex } = import.meta.require("node:stream"); const { URL } = import.meta.require("node:url"); const { newArrayWithSize, String, Object, Array } = import.meta.primordials; @@ -39,7 +40,9 @@ const NODE_HTTP_WARNING = var _globalAgent; var _defaultHTTPSAgent; var kInternalRequest = Symbol("kInternalRequest"); +var kInternalSocketData = Symbol.for("::bunternal::"); +const kEmptyBuffer = Buffer.alloc(0); var FakeSocket = class Socket extends Duplex { bytesRead = 0; bytesWritten = 0; @@ -318,7 +321,14 @@ export class Server extends EventEmitter { } address() { - return this.#server?.hostname; + if (!this.#server) return null; + + const address = this.#server.hostname; + return { + address, + family: isIPv6(address) ? "IPv6" : "IPv4", + port: this.#server.port, + }; } listen(port, host, onListen) { @@ -347,8 +357,30 @@ export class Server extends EventEmitter { this.#server = Bun.serve({ port, hostname: host, - - fetch(req) { + // Bindings to be used for WS Server + websocket: { + open(ws) { + if (ws.data && typeof ws.data.open === "function") { + ws.data.open(ws); + } + }, + message(ws, message) { + if (ws.data && typeof ws.data.message === "function") { + ws.data.message(ws, message); + } + }, + close(ws, code, reason) { + if (ws.data && typeof ws.data.close === "function") { + ws.data.close(ws, code, reason); + } + }, + drain(ws) { + if (ws.data && typeof ws.data.drain === "function") { + ws.data.drain(ws); + } + }, + }, + fetch(req, _server) { var pendingResponse; var pendingError; var rejectFunction, resolveFunction; @@ -369,7 +401,15 @@ export class Server extends EventEmitter { http_req.once("error", err => reject(err)); http_res.once("error", err => reject(err)); - server.emit("request", http_req, http_res); + + const upgrade = req.headers.get("upgrade"); + if (upgrade) { + const socket = new FakeSocket(); + socket[kInternalSocketData] = [_server, http_res]; + server.emit("upgrade", http_req, socket, kEmptyBuffer); + } else { + server.emit("request", http_req, http_res); + } if (pendingError) { throw pendingError; diff --git a/src/bun.js/ws.exports.js b/src/bun.js/ws.exports.js index 864ac20d3..9c6fc0aea 100644 --- a/src/bun.js/ws.exports.js +++ b/src/bun.js/ws.exports.js @@ -2,6 +2,11 @@ // this just wraps WebSocket to look like an EventEmitter // without actually using an EventEmitter polyfill +import EventEmitter from "events"; +import http from "http"; + +const kBunInternals = Symbol.for("::bunternal::"); + class BunWebSocket extends globalThis.WebSocket { constructor(url, ...args) { super(url, ...args); @@ -64,9 +69,819 @@ class BunWebSocket extends globalThis.WebSocket { BunWebSocket.WebSocket = BunWebSocket; -class Server { - constructor() { - throw new Error("Not supported yet in Bun"); +const wsKeyRegex = /^[+/0-9A-Za-z]{22}==$/; +const wsTokenChars = [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, // 0 - 15 + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, // 16 - 31 + 0, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 1, + 1, + 0, + 1, + 1, + 0, // 32 - 47 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, // 48 - 63 + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, // 64 - 79 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, // 80 - 95 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, // 96 - 111 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 0, + 1, + 0, // 112 - 127 +]; + +/** + * Parses the `Sec-WebSocket-Protocol` header into a set of subprotocol names. + * + * @param {String} header The field value of the header + * @return {Set} The subprotocol names + * @public + */ +function subprotocolParse(header) { + const protocols = new Set(); + let start = -1; + let end = -1; + let i = 0; + + for (i; i < header.length; i++) { + const code = header.charCodeAt(i); + + if (end === -1 && wsTokenChars[code] === 1) { + if (start === -1) start = i; + } else if (i !== 0 && (code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */) { + if (end === -1 && start !== -1) end = i; + } else if (code === 0x2c /* ',' */) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + + if (end === -1) end = i; + + const protocol = header.slice(start, end); + + if (protocols.has(protocol)) { + throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); + } + + protocols.add(protocol); + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } + + if (start === -1 || end !== -1) { + throw new SyntaxError("Unexpected end of input"); + } + + const protocol = header.slice(start, i); + + if (protocols.has(protocol)) { + throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); + } + + protocols.add(protocol); + return protocols; +} + +/** + * Emit a `'close'` event on an `EventEmitter`. + * + * @param {EventEmitter} server The event emitter + * @private + */ +function wsEmitClose(server) { + server._state = CLOSED; + server.emit("close"); +} + +function abortHandshake(response, code, message, headers) { + message = message || http.STATUS_CODES[code]; + headers = { + Connection: "close", + "Content-Type": "text/html", + "Content-Length": Buffer.byteLength(message), + ...headers, + }; + + response.writeHead(code, headers); + response.write(message); + response.end(); +} + +function abortHandshakeOrEmitwsClientError(server, req, response, socket, code, message) { + if (server.listenerCount("wsClientError")) { + const err = new Error(message); + Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError); + + server.emit("wsClientError", err, socket, req); + } else { + abortHandshake(response, code, message); + } +} + +const RUNNING = 0; +const CLOSING = 1; +const CLOSED = 2; +const OPEN_EVENT = new Event("open"); + +class BunWebSocketMocked extends EventEmitter { + #ws; + #state; + #enquedMessages = []; + #url; + #protocol; + #extensions; + #bufferedAmount = 0; + #binaryType = "blob"; + + #onclose; + #onerror; + #onmessage; + #onopen; + + constructor(url, protocol, extensions) { + super(); + this.#ws = null; + this.#state = 0; + this.#url = url; + this.#bufferedAmount = 0; + this.#binaryType = "blob"; + this.#protocol = protocol; + this.#extensions = extensions; + this.binaryType = binaryType; + + const message = this.#message.bind(this); + const open = this.#open.bind(this); + const close = this.#close.bind(this); + const drain = this.#drain.bind(this); + + [kBunInternals] = { + message, // a message is received + open, // a socket is opened + close, // a socket is closed + drain, // the socket is ready to receive more data + }; + } + + #message(ws, message) { + this.#ws = ws; + + if (typeof message === "string") { + if (this.#binaryType === "blob") { + message = new Blob([message], { type: "text/plain" }); + } else if (this.#binaryType === "arraybuffer") { + const encoder = new TextEncoder(); + message = encoder.encode(message).buffer; + } else { + // nodebuffer or buffer + message = Buffer.from(message); + } + } else { + //Uint8Array + if (this.#binaryType === "blob") { + message = new Blob([message]); + } else if (this.#binaryType === "arraybuffer") { + message = message.buffer; + } else { + // nodebuffer or buffer + message = Buffer.from(message); + } + } + this.emit( + "message", + new MessageEvent("message", { + cancelable: false, + data: message, + source: this, + }), + ); + } + + #open(ws) { + this.#ws = ws; + this.#state = 1; + this.emit("open", OPEN_EVENT); + } + + #close(ws, code, reason) { + this.#ws = null; + this.#state = 3; + + this.emit( + "close", + new CloseEvent("close", { + code, + reason, + wasClean: true, + }), + ); + } + + #drain(ws) { + this.#ws = ws; + const data = this.#enquedMessages[0]; + const length = data.length - start; + const written = ws.send(data.slice(start)); + if (written == -1) { + // backpressure wait until next drain event + return; + } + + this.#bufferedAmount -= length; + this.#enquedMessages.shift(); + } + + send(data) { + if (this.#state !== 1) { + return; + } + const written = this.#ws.send(data); + if (written == -1) { + const length = data.length; + // backpressure + this.#enquedMessages.push([data, length]); + this.#bufferedAmount += length; + return; + } + } + + close(code, reason) { + if (this.#state === 1) { + this.#state = 2; + this.#ws.close(code, reason); + } + } + get binaryType() { + return this.#binaryType; + } + + set binaryType(type) { + if (type !== "nodebuffer" && type !== "buffer" && type !== "blob" && type !== "arraybuffer") { + throw new TypeError("binaryType must be either 'blob', 'arraybuffer', 'nodebuffer' or 'buffer'"); + } + this.#binaryType = type; + } + + get readyState() { + return this.#state; + } + get url() { + return this.#url; + } + + get protocol() { + return this.#protocol; + } + + get extensions() { + return this.#extensions; + } + + get bufferedAmount() { + return this.#bufferedAmount ?? 0; + } + /** + * Set up the socket and the internal resources. + * + * @param {(net.Socket|tls.Socket)} socket The network socket between the + * server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Object} options Options object + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Number} [options.maxPayload=0] The maximum allowed message size + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @private + */ + setSocket(socket, head, options) { + throw new Error("Not implemented"); + } + + set onclose(cb) { + if (this.#onclose) { + this.removeListener("close", this.#onclose); + } + this.on("close", cb); + this.#onclose = cb; + } + + set onerror(cb) { + if (this.#onerror) { + this.removeListener("error", this.#onerror); + } + this.on("error", cb); + this.#onerror = cb; + } + + set onmessage(cb) { + if (this.#onmessage) { + this.removeListener("message", this.#onmessage); + } + this.on("message", cb); + this.#onmessage = cb; + } + + set onopen(cb) { + if (this.#onopen) { + this.removeListener("open", this.#onopen); + } + this.on("open", cb); + this.#onopen = cb; + } + + get onclose() { + return this.#onclose; + } + + get onerror() { + return this.#onerror; + } + + get onmessage() { + return this.#onmessage; + } + + get onopen() { + return this.#onopen; + } +} +class Server extends EventEmitter { + _server; + options; + clients; + _shouldEmitClose; + _state; + _removeListeners; + + /** + * Create a `WebSocketServer` instance. + * + * @param {Object} options Configuration options + * @param {Number} [options.backlog=511] The maximum length of the queue of + * pending connections + * @param {Boolean} [options.clientTracking=true] Specifies whether or not to + * track clients + * @param {Function} [options.handleProtocols] A hook to handle protocols + * @param {String} [options.host] The hostname where to bind the server + * size + * @param {Boolean} [options.noServer=false] Enable no server mode + * @param {String} [options.path] Accept only connections matching this path + * @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable + * permessage-deflate + * @param {Number} [options.port] The port where to bind the server + * @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S + * @param {Function} [options.verifyClient] A hook to reject connections + * class to use. It must be the `WebSocket` class or class that extends it + * @param {Function} [callback] A listener for the `listening` event + */ + constructor(options, callback) { + super(); + + options = { + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: false, + handleProtocols: null, + clientTracking: true, + verifyClient: null, + noServer: false, + backlog: null, // use default (511 as implemented in net.js) + server: null, + host: null, + path: null, + port: null, + ...options, + }; + + if ( + (options.port == null && !options.server && !options.noServer) || + (options.port != null && (options.server || options.noServer)) || + (options.server && options.noServer) + ) { + throw new TypeError('One and only one of the "port", "server", or "noServer" options must be specified'); + } + + if (options.port != null) { + this._server = http.createServer((req, res) => { + const body = http.STATUS_CODES[426]; + + res.writeHead(426, { + "Content-Length": body.length, + "Content-Type": "text/plain", + }); + res.end(body); + }); + this._server.listen(options.port, options.host, options.backlog, callback); + } else if (options.server) { + this._server = options.server; + } + + if (this._server) { + const emitConnection = this.emit.bind(this, "connection"); + const emitListening = this.emit.bind(this, "listening"); + const emitError = this.emit.bind(this, "error"); + const doUpgrade = (req, socket, head) => { + this.handleUpgrade(req, socket, head, emitConnection); + }; + + this._server.on("listening", emitListening); + this._server.on("error", emitError); + this._server.on("upgrade", doUpgrade); + + this._removeListeners = () => { + this._server.removeListener("upgrade", doUpgrade); + this._server.removeListener("listening", emitListening); + this._server.removeListener("error", emitError); + }; + } + + if (options.perMessageDeflate === true) options.perMessageDeflate = {}; + if (options.clientTracking) { + this.clients = new Set(); + this._shouldEmitClose = false; + } + + this.options = options; + this._state = RUNNING; + } + + /** + * Returns the bound address, the address family name, and port of the server + * as reported by the operating system if listening on an IP socket. + * If the server is listening on a pipe or UNIX domain socket, the name is + * returned as a string. + * + * @return {(Object|String|null)} The address of the server + * @public + */ + address() { + if (this.options.noServer) { + throw new Error('The server is operating in "noServer" mode'); + } + + if (!this._server) return null; + return this._server.address(); + } + + /** + * Stop the server from accepting new connections and emit the `'close'` event + * when all existing connections are closed. + * + * @param {Function} [cb] A one-time listener for the `'close'` event + * @public + */ + close(cb) { + if (this._state === CLOSED) { + if (cb) { + this.once("close", () => { + cb(new Error("The server is not running")); + }); + } + + process.nextTick(server => { + server._state = CLOSED; + server.emit("close"); + }, this); + return; + } + + if (cb) this.once("close", cb); + + if (this._state === CLOSING) return; + this._state = CLOSING; + + if (this.options.noServer || this.options.server) { + if (this._server) { + this._removeListeners(); + this._removeListeners = this._server = null; + } + + if (this.clients) { + if (!this.clients.size) { + process.nextTick(server => { + server._state = CLOSED; + server.emit("close"); + }, this); + } else { + this._shouldEmitClose = true; + } + } else { + process.nextTick(server => { + server._state = CLOSED; + server.emit("close"); + }, this); + } + } else { + const server = this._server; + + this._removeListeners(); + this._removeListeners = this._server = null; + + // + // The HTTP/S server was created internally. Close it, and rely on its + // `'close'` event. + // + server.close(() => { + this._state = CLOSED; + this.emit("close"); + }); + } + } + + /** + * See if a given request should be handled by this server instance. + * + * @param {http.IncomingMessage} req Request object to inspect + * @return {Boolean} `true` if the request is valid, else `false` + * @public + */ + shouldHandle(req) { + if (this.options.path) { + const index = req.url.indexOf("?"); + const pathname = index !== -1 ? req.url.slice(0, index) : req.url; + + if (pathname !== this.options.path) return false; + } + + return true; + } + + /** + * Upgrade the connection to WebSocket. + * + * @param {Object} extensions The accepted extensions + * @param {String} key The value of the `Sec-WebSocket-Key` header + * @param {Set} protocols The subprotocols + * @param {http.IncomingMessage} request The request object + * @param {(net.Socket|tls.Socket)} socket The network socket between the + * server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Function} cb Callback + * @throws {Error} If called more than once with the same socket + * @private + */ + completeUpgrade(extensions, key, protocols, request, socket, head, cb) { + const [server, response] = socket[kBunInternals]; + if (this._state > RUNNING) return abortHandshake(response, 503); + + let protocol = ""; + if (protocols.size) { + // + // Optionally call external protocol selection handler. + // + protocol = this.options.handleProtocols + ? this.options.handleProtocols(protocols, request) + : protocols.values().next().value; + } + const ws = new BunWebSocketMocked(request.url, protocol, extensions); + + const headers = ["HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", "Connection: Upgrade"]; + + this.emit("headers", headers, req); + + if ( + server.upgrade(request.req, { + data: ws, + headers, + }) + ) { + response._reply(undefined); + if (this.clients) { + this.clients.add(ws); + ws.on("close", () => { + this.clients.delete(ws); + + if (this._shouldEmitClose && !this.clients.size) { + process.nextTick(wsEmitClose, this); + } + }); + } + cb(ws, request); + } else { + abortHandshake(response, 500); + } + } + /** + * Handle a HTTP Upgrade request. + * + * @param {http.IncomingMessage} req The request object + * @param {(net.Socket|tls.Socket)} socket The network socket between the + * server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Function} cb Callback + * @public + */ + handleUpgrade(req, socket, head, cb) { + // socket is actually fake so we use internal http_res + const [_, response] = socket[kBunInternals]; + + // socket.on("error", socketOnError); + + const key = req.headers["sec-websocket-key"]; + const version = +req.headers["sec-websocket-version"]; + + if (req.method !== "GET") { + const message = "Invalid HTTP method"; + abortHandshakeOrEmitwsClientError(this, req, response, socket, 405, message); + return; + } + + if (req.headers.upgrade.toLowerCase() !== "websocket") { + const message = "Invalid Upgrade header"; + abortHandshakeOrEmitwsClientError(this, req, response, socket, 400, message); + return; + } + + if (!key || !wsKeyRegex.test(key)) { + const message = "Missing or invalid Sec-WebSocket-Key header"; + abortHandshakeOrEmitwsClientError(this, req, response, socket, 400, message); + return; + } + + if (version !== 8 && version !== 13) { + const message = "Missing or invalid Sec-WebSocket-Version header"; + abortHandshakeOrEmitwsClientError(this, req, response, socket, 400, message); + return; + } + + if (!this.shouldHandle(req)) { + abortHandshake(response, 400); + return; + } + + const secWebSocketProtocol = req.headers["sec-websocket-protocol"]; + let protocols = new Set(); + + if (secWebSocketProtocol !== undefined) { + try { + protocols = subprotocolParse(secWebSocketProtocol); + } catch (err) { + const message = "Invalid Sec-WebSocket-Protocol header"; + abortHandshakeOrEmitwsClientError(this, req, response, socket, 400, message); + return; + } + } + + // TODO: add perMessageDeflate options + + const secWebSocketExtensions = req.headers["sec-websocket-extensions"]; + // const extensions = {}; + + if (secWebSocketExtensions !== undefined) { + // const perMessageDeflate = new PerMessageDeflate(this.options.perMessageDeflate, true, this.options.maxPayload); + + // try { + // const offers = extension.parse(secWebSocketExtensions); + + // if (offers[PerMessageDeflate.extensionName]) { + // perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]); + // extensions[PerMessageDeflate.extensionName] = perMessageDeflate; + // } + // } catch (err) { + const message = "Invalid or unacceptable Sec-WebSocket-Extensions header"; + abortHandshakeOrEmitwsClientError(this, req, response, socket, 400, message); + return; + // } + } + + // + // Optionally call external client verification handler. + // + if (this.options.verifyClient) { + const info = { + origin: req.headers[`${version === 8 ? "sec-websocket-origin" : "origin"}`], + secure: !!(req.socket.authorized || req.socket.encrypted), + req, + }; + + if (this.options.verifyClient.length === 2) { + this.options.verifyClient(info, (verified, code, message, headers) => { + if (!verified) { + return abortHandshake(response, code || 401, message, headers); + } + + this.completeUpgrade(extensions, key, protocols, req, socket, head, cb); + }); + return; + } + + if (!this.options.verifyClient(info)) return abortHandshake(response, 401); + } + + this.completeUpgrade(extensions, key, protocols, req, socket, head, cb); } } diff --git a/test/js/third_party/socket.io/fixtures/server-close.ts b/test/js/third_party/socket.io/fixtures/server-close.ts new file mode 100644 index 000000000..e5d47bba8 --- /dev/null +++ b/test/js/third_party/socket.io/fixtures/server-close.ts @@ -0,0 +1,11 @@ +const server = require("http").createServer(); +const ioc = require("socket.io-client"); +const io = require("socket.io")(server); + +const srv = server.listen(() => { + const socket = ioc.connect("ws://localhost:" + server.address().port); + socket.on("connect", () => { + io.close(); + socket.close(); + }); +}); diff --git a/test/js/third_party/socket.io/package.json b/test/js/third_party/socket.io/package.json new file mode 100644 index 000000000..edeb84845 --- /dev/null +++ b/test/js/third_party/socket.io/package.json @@ -0,0 +1,10 @@ +{ + "name": "socket.io", + "version": "1.0.0", + "dependencies": { + "socket.io": "^4.6.1", + "socket.io-client": "^4.6.1", + "supertest": "^6.1.6", + "uWebSockets.js": "uNetworking/uWebSockets.js#v20.24.0" + } +} diff --git a/test/js/third_party/socket.io/socket.io-close.test.ts b/test/js/third_party/socket.io/socket.io-close.test.ts new file mode 100644 index 000000000..23b9277c1 --- /dev/null +++ b/test/js/third_party/socket.io/socket.io-close.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect } from "bun:test"; +import { io as ioc } from "socket.io-client"; +import { join } from "path"; +import { createServer } from "http"; +import { createClient, getPort, success, fail, eioHandshake, eioPoll, eioPush } from "./support/util.ts"; +import { Server } from "socket.io"; +import { exec, ChildProcess } from "child_process"; + +describe("close", () => { + it.skip("should be able to close sio sending a srv", done => { + const httpServer = createServer().listen(0); + const io = new Server(httpServer); + const port = getPort(io); + const net = require("net"); + const server = net.createServer(); + + const clientSocket = createClient(io, "/", { reconnection: false }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 200); + + clientSocket.on("disconnect", () => { + try { + expect(io.sockets.sockets.size).toBe(0); + } catch (err) { + fail(done, io, err, clientSocket); + } + server.listen(port); + }); + + clientSocket.on("connect", () => { + try { + expect(io.sockets.sockets.size).toBe(1); + io.close(); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + + server.once("listening", () => { + // PORT should be free + server.close((error: any) => { + clearTimeout(timeout); + try { + expect(error).toBe(undefined); + success(done, io, clientSocket); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + }); + }); + + it.skip("should be able to close sio sending a srv", done => { + const io = new Server(0); + const port = getPort(io); + const net = require("net"); + const server = net.createServer(); + + const clientSocket = ioc("ws://0.0.0.0:" + port, { + reconnection: false, + }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 200); + + clientSocket.on("disconnect", () => { + try { + expect(io.sockets.sockets.size).toBe(0); + } catch (err) { + fail(done, io, err, clientSocket); + } + server.listen(port); + }); + + clientSocket.on("connect", () => { + try { + expect(io.sockets.sockets.size).toBe(1); + io.close(); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + + server.once("listening", () => { + // PORT should be free + server.close((error: any) => { + clearTimeout(timeout); + try { + expect(error).toBe(undefined); + success(done, io, clientSocket); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + }); + }); + + describe("graceful close", () => { + function fixture(filename: string) { + return '"' + process.execPath + '" "' + join(__dirname, "fixtures", filename) + '"'; + } + + it("should stop socket and timers", done => { + let process: ChildProcess; + const timeout = setTimeout(() => { + process?.kill(); + done(new Error("timeout")); + }, 3000); + + process = exec(fixture("server-close.ts"), err => { + clearTimeout(timeout); + done(err); + }); + }); + }); + + describe("protocol violations", () => { + it.skip("should close the connection when receiving several CONNECT packets", done => { + const httpServer = createServer(); + const io = new Server(httpServer); + + httpServer.listen(0); + + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 1500); + + (async () => { + const sid = await eioHandshake(httpServer); + // send a first CONNECT packet + await eioPush(httpServer, sid, "40"); + // send another CONNECT packet + await eioPush(httpServer, sid, "40"); + // session is cleanly closed (not discarded, see 'client.close()') + // first, we receive the Socket.IO handshake response + await eioPoll(httpServer, sid); + // then a close packet + return await eioPoll(httpServer, sid); + })().then(body => { + clearTimeout(timeout); + try { + expect(body).toBe("6\u001e1"); + + io.close(); + success(done, io); + } catch (err) { + fail(done, io, err); + } + }); + }); + + // TODO: IOT instruction can happen here + it.skip("should close the connection when receiving an EVENT packet while not connected", done => { + const httpServer = createServer(); + const io = new Server(httpServer); + + httpServer.listen(0); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 1500); + + (async () => { + const sid = await eioHandshake(httpServer); + // send an EVENT packet + await eioPush(httpServer, sid, '42["some event"]'); + // session is cleanly closed, we receive a close packet + return await eioPoll(httpServer, sid); + })().then(body => { + clearTimeout(timeout); + try { + expect(body).toBe("6\u001e1"); + + io.close(); + success(done, io); + } catch (err) { + fail(done, io, err); + } + }); + }); + + // TODO: investigatye IOT instruction + it.skip("should close the connection when receiving an invalid packet", done => { + const httpServer = createServer(); + const io = new Server(httpServer); + + httpServer.listen(0); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 1500); + + (async () => { + const sid = await eioHandshake(httpServer); + // send a CONNECT packet + await eioPush(httpServer, sid, "40"); + // send an invalid packet + await eioPush(httpServer, sid, "4abc"); + // session is cleanly closed (not discarded, see 'client.close()') + // first, we receive the Socket.IO handshake response + await eioPoll(httpServer, sid); + // then a close packet + return await eioPoll(httpServer, sid); + })().then(body => { + clearTimeout(timeout); + try { + expect(body).toBe("6\u001e1"); + + io.close(); + success(done, io); + } catch (err) { + fail(done, io, err); + } + }); + }); + }); +}); diff --git a/test/js/third_party/socket.io/socket.io-connection-state-recovery.test.ts b/test/js/third_party/socket.io/socket.io-connection-state-recovery.test.ts new file mode 100644 index 000000000..fbf7b2035 --- /dev/null +++ b/test/js/third_party/socket.io/socket.io-connection-state-recovery.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect } from "bun:test"; + +import { Server, Socket } from "socket.io"; +import { waitFor, eioHandshake, eioPush, eioPoll, fail, success } from "./support/util.ts"; +import { createServer, Server as HttpServer } from "http"; +import { Adapter } from "socket.io-adapter"; + +async function init(httpServer: HttpServer, io: Server) { + // Engine.IO handshake + const sid = await eioHandshake(httpServer); + + // Socket.IO handshake + await eioPush(httpServer, sid, "40"); + const handshakeBody = await eioPoll(httpServer, sid); + + expect(handshakeBody.startsWith("40")).toBe(true); + + const handshake = JSON.parse(handshakeBody.substring(2)); + + expect(handshake.sid).not.toBe(undefined); + // in that case, the handshake also contains a private session ID + expect(handshake.pid).not.toBe(undefined); + + io.emit("hello"); + + const message = await eioPoll(httpServer, sid); + + expect(message.startsWith('42["hello"')).toBe(true); + + const offset = JSON.parse(message.substring(2))[1]; + // in that case, each packet also includes an offset in the data array + expect(offset).not.toBe(undefined); + + await eioPush(httpServer, sid, "1"); + return [handshake.sid, handshake.pid, offset]; +} + +describe("connection state recovery", () => { + it.skip("should restore session and missed packets", done => { + const httpServer = createServer().listen(0); + const io = new Server(httpServer, { + connectionStateRecovery: {}, + }); + + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + + let serverSocket: Socket | undefined; + + io.once("connection", socket => { + socket.join("room1"); + serverSocket = socket; + }); + + (async () => { + try { + const [sid, pid, offset] = await init(httpServer, io); + + io.emit("hello1"); // broadcast + io.to("room1").emit("hello2"); // broadcast to room + serverSocket?.emit("hello3"); // direct message + + const newSid = await eioHandshake(httpServer); + await eioPush(httpServer, newSid, `40{"pid":"${pid}","offset":"${offset}"}`); + + const payload = await eioPoll(httpServer, newSid); + clearTimeout(timeout); + + const packets = payload.split("\x1e"); + + expect(packets.length).toBe(4); + + // note: EVENT packets are received before the CONNECT packet, which is a bit weird + // see also: https://github.com/socketio/socket.io-deno/commit/518f534e1c205b746b1cb21fe76b187dabc96f34 + expect(packets[0].startsWith('42["hello1"')).toBe(true); + expect(packets[1].startsWith('42["hello2"')).toBe(true); + expect(packets[2].startsWith('42["hello3"')).toBe(true); + expect(packets[3]).toBe(`40{"sid":"${sid}","pid":"${pid}"}`); + success(done, io); + } catch (err) { + fail(done, io, err); + } + })(); + }); + + it.skip("should restore rooms and data attributes", done => { + const httpServer = createServer().listen(0); + const io = new Server(httpServer, { + connectionStateRecovery: {}, + }); + + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + + io.once("connection", socket => { + expect(socket.recovered).toBe(false); + + socket.join("room1"); + socket.join("room2"); + socket.data.foo = "bar"; + }); + (async () => { + try { + const [sid, pid, offset] = await init(httpServer, io); + + const newSid = await eioHandshake(httpServer); + + const [socket] = await Promise.all([ + waitFor<Socket>(io, "connection"), + eioPush(httpServer, newSid, `40{"pid":"${pid}","offset":"${offset}"}`), + ]); + + clearTimeout(timeout); + expect(socket.id).toBe(sid); + expect(socket.recovered).toBe(true); + + expect(socket.rooms.has(socket.id)).toBe(true); + expect(socket.rooms.has("room1")).toBe(true); + expect(socket.rooms.has("room2")).toBe(true); + + expect(socket.data.foo).toBe("bar"); + + await eioPoll(httpServer, newSid); // drain buffer + success(done, io); + } catch (err) { + fail(done, io, err); + } + })(); + }); + + it.skip("should not run middlewares upon recovery by default", done => { + const httpServer = createServer().listen(0); + const io = new Server(httpServer, { + connectionStateRecovery: {}, + }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + + (async () => { + try { + const [_, pid, offset] = await init(httpServer, io); + + io.use((socket, next) => { + socket.data.middlewareWasCalled = true; + + next(); + }); + + const newSid = await eioHandshake(httpServer); + + const [socket] = await Promise.all([ + waitFor<Socket>(io, "connection"), + eioPush(httpServer, newSid, `40{"pid":"${pid}","offset":"${offset}"}`), + ]); + + clearTimeout(timeout); + expect(socket.recovered).toBe(true); + expect(socket.data.middlewareWasCalled).toBe(undefined); + + await eioPoll(httpServer, newSid); // drain buffer + success(done, io); + } catch (err) { + fail(done, io, err); + } + })(); + }); + + it.skip("should run middlewares even upon recovery", done => { + const httpServer = createServer().listen(0); + const io = new Server(httpServer, { + connectionStateRecovery: { + skipMiddlewares: false, + }, + }); + + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + + (async () => { + try { + const [_, pid, offset] = await init(httpServer, io); + + io.use((socket, next) => { + socket.data.middlewareWasCalled = true; + + next(); + }); + + const newSid = await eioHandshake(httpServer); + + const [socket] = await Promise.all([ + waitFor<Socket>(io, "connection"), + eioPush(httpServer, newSid, `40{"pid":"${pid}","offset":"${offset}"}`), + ]); + + clearTimeout(timeout); + + expect(socket.recovered).toBe(true); + expect(socket.data.middlewareWasCalled).toBe(true); + + await eioPoll(httpServer, newSid); // drain buffer + success(done, io); + } catch (err) { + fail(done, io, err); + } + })(); + }); + + it.skip("should fail to restore an unknown session", done => { + const httpServer = createServer().listen(0); + const io = new Server(httpServer, { + connectionStateRecovery: {}, + }); + + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + + (async () => { + try { + // Engine.IO handshake + const sid = await eioHandshake(httpServer); + + // Socket.IO handshake + await eioPush(httpServer, sid, '40{"pid":"foo","offset":"bar"}'); + + const handshakeBody = await eioPoll(httpServer, sid); + + clearTimeout(timeout); + + expect(handshakeBody.startsWith("40")).toBe(true); + + const handshake = JSON.parse(handshakeBody.substring(2)); + + expect(handshake.sid).not.toBe("foo"); + expect(handshake.pid).not.toBe("bar"); + + success(done, io); + } catch (err) { + fail(done, io, err); + } + })(); + }); + + it("should be disabled by default", done => { + const httpServer = createServer().listen(0); + const io = new Server(httpServer); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + + (async () => { + try { + // Engine.IO handshake + const sid = await eioHandshake(httpServer); + + // Socket.IO handshake + await eioPush(httpServer, sid, "40"); + + const handshakeBody = await eioPoll(httpServer, sid); + + clearTimeout(timeout); + expect(handshakeBody.startsWith("40")).toBe(true); + + const handshake = JSON.parse(handshakeBody.substring(2)); + + expect(handshake.sid).not.toBe(undefined); + expect(handshake.pid).toBe(undefined); + + success(done, io); + } catch (err) { + fail(done, io, err); + } + })(); + }); + + it("should not call adapter#persistSession or adapter#restoreSession if disabled", done => { + const httpServer = createServer().listen(0); + + let io: Server; + class DummyAdapter extends Adapter { + override persistSession() { + fail(done, io, new Error("should not happen")); + return Promise.reject("should not happen"); + } + + override restoreSession() { + fail(done, io, new Error("should not happen")); + return Promise.reject("should not happen"); + } + } + + io = new Server(httpServer, { + adapter: DummyAdapter, + }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + + (async () => { + try { + // Engine.IO handshake + const sid = await eioHandshake(httpServer); + + await eioPush(httpServer, sid, '40{"pid":"foo","offset":"bar"}'); + await eioPoll(httpServer, sid); + await eioPush(httpServer, sid, "1"); + clearTimeout(timeout); + success(done, io); + } catch (err) { + fail(done, io, err); + } + })(); + }); +}); diff --git a/test/js/third_party/socket.io/socket.io-handshake.test.ts b/test/js/third_party/socket.io/socket.io-handshake.test.ts new file mode 100644 index 000000000..e29808080 --- /dev/null +++ b/test/js/third_party/socket.io/socket.io-handshake.test.ts @@ -0,0 +1,118 @@ +import { Server } from "socket.io"; +import { describe, it, expect } from "bun:test"; +import { getPort, success, fail } from "./support/util.ts"; + +describe("handshake", () => { + const request = require("superagent"); + + it("should send the Access-Control-Allow-xxx headers on OPTIONS request", done => { + const io = new Server(0, { + cors: { + origin: "http://localhost:54023", + methods: ["GET", "POST"], + allowedHeaders: ["content-type"], + credentials: true, + }, + }); + + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + + request + .options(`http://localhost:${getPort(io)}/socket.io/default/`) + .query({ transport: "polling", EIO: 4 }) + .set("Origin", "http://localhost:54023") + .end((err, res) => { + try { + clearTimeout(timeout); + expect(res.status).toBe(204); + + expect(res.headers["access-control-allow-origin"]).toBe("http://localhost:54023"); + expect(res.headers["access-control-allow-methods"]).toBe("GET,POST"); + expect(res.headers["access-control-allow-headers"]).toBe("content-type"); + expect(res.headers["access-control-allow-credentials"]).toBe("true"); + success(done, io); + } catch (err) { + fail(done, io, err); + } + }); + }); + + it("should send the Access-Control-Allow-xxx headers on GET request", done => { + const io = new Server(0, { + cors: { + origin: "http://localhost:54024", + methods: ["GET", "POST"], + allowedHeaders: ["content-type"], + credentials: true, + }, + }); + + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + + request + .get(`http://localhost:${getPort(io)}/socket.io/default/`) + .query({ transport: "polling", EIO: 4 }) + .set("Origin", "http://localhost:54024") + .end((err, res) => { + clearTimeout(timeout); + try { + expect(res.status).toBe(200); + + expect(res.headers["access-control-allow-origin"]).toBe("http://localhost:54024"); + expect(res.headers["access-control-allow-credentials"]).toBe("true"); + success(done, io); + } catch (err) { + fail(done, io, err); + } + }); + }); + + it("should allow request if custom function in opts.allowRequest returns true", done => { + const io = new Server(0, { + allowRequest: (req, callback) => callback(null, true), + }); + + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + + request + .get(`http://localhost:${getPort(io)}/socket.io/default/`) + .query({ transport: "polling", EIO: 4 }) + .end((err, res) => { + try { + clearTimeout(timeout); + expect(res.status).toBe(200); + success(done, io); + } catch (err) { + fail(done, io, err); + } + }); + }); + + it("should disallow request if custom function in opts.allowRequest returns false", done => { + const io = new Server(0, { + allowRequest: (req, callback) => callback(null, false), + }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + request + .get(`http://localhost:${getPort(io)}/socket.io/default/`) + .set("origin", "http://foo.example") + .query({ transport: "polling", EIO: 4 }) + .end((err, res) => { + try { + clearTimeout(timeout); + expect(res.status).toBe(403); + success(done, io); + } catch (err) { + fail(done, io, err); + } + }); + }); +}); diff --git a/test/js/third_party/socket.io/socket.io-messaging-many.test.ts b/test/js/third_party/socket.io/socket.io-messaging-many.test.ts new file mode 100644 index 000000000..44b80928c --- /dev/null +++ b/test/js/third_party/socket.io/socket.io-messaging-many.test.ts @@ -0,0 +1,766 @@ +import { Server } from "socket.io"; +import { describe, it, expect } from "bun:test"; +import { createClient, createPartialDone, success, fail, waitFor } from "./support/util"; + +describe("messaging many", () => { + it("emits to a namespace", done => { + const io = new Server(0); + const socket1 = createClient(io, "/", { multiplex: false }); + const socket2 = createClient(io, "/", { multiplex: false }); + const socket3 = createClient(io, "/test"); + + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2, socket3); + }, 200); + + const partialDone = createPartialDone(2, err => { + clearTimeout(timeout); + if (err) return fail(done, io, err, socket1, socket2, socket3); + success(done, io, socket1, socket2, socket3); + }); + + socket1.on("a", a => { + try { + expect(a).toBe("b"); + partialDone(); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, socket1, socket2, socket3); + } + }); + socket2.on("a", a => { + try { + expect(a).toBe("b"); + partialDone(); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, socket1, socket2, socket3); + } + }); + socket3.on("a", () => { + clearTimeout(timeout); + fail(done, io, new Error("not"), socket1, socket2, socket3); + }); + + let sockets = 3; + io.on("connection", () => { + --sockets || emit(); + }); + io.of("/test", () => { + --sockets || emit(); + }); + + function emit() { + io.emit("a", "b"); + } + }); + + it("emits binary data to a namespace", done => { + const io = new Server(0); + const socket1 = createClient(io, "/", { multiplex: false }); + const socket2 = createClient(io, "/", { multiplex: false }); + const socket3 = createClient(io, "/test"); + + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2, socket3); + }, 200); + + const partialDone = createPartialDone(2, err => { + clearTimeout(timeout); + if (err) return fail(done, io, err, socket1, socket2, socket3); + success(done, io, socket1, socket2, socket3); + }); + + socket1.on("bin", a => { + try { + expect(Buffer.isBuffer(a)).toBe(true); + partialDone(); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, socket1, socket2, socket3); + } + }); + socket2.on("bin", a => { + try { + expect(Buffer.isBuffer(a)).toBe(true); + partialDone(); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, socket1, socket2, socket3); + } + }); + socket3.on("bin", () => { + clearTimeout(timeout); + fail(done, io, new Error("not"), socket1, socket2, socket3); + }); + + let sockets = 3; + io.on("connection", () => { + --sockets || emit(); + }); + io.of("/test", () => { + --sockets || emit(); + }); + + function emit() { + io.emit("bin", Buffer.alloc(10)); + } + }); + + it("emits to the rest", done => { + const io = new Server(0); + const socket1 = createClient(io, "/", { multiplex: false }); + const socket2 = createClient(io, "/", { multiplex: false }); + const socket3 = createClient(io, "/test"); + + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2, socket3); + }, 200); + + socket1.on("a", a => { + try { + expect(a).toBe("b"); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, socket1, socket2, socket3); + } + socket1.emit("finish"); + }); + socket2.emit("broadcast"); + socket2.on("a", () => { + clearTimeout(timeout); + fail(done, io, new Error("done"), socket1, socket2, socket3); + }); + socket3.on("a", () => { + clearTimeout(timeout); + fail(done, io, new Error("not"), socket1, socket2, socket3); + }); + + io.on("connection", socket => { + socket.on("broadcast", () => { + socket.broadcast.emit("a", "b"); + }); + socket.on("finish", () => { + clearTimeout(timeout); + success(done, io, socket1, socket2, socket3); + }); + }); + }); + + it("emits to rooms", done => { + const io = new Server(0); + const socket1 = createClient(io, "/", { multiplex: false }); + const socket2 = createClient(io, "/", { multiplex: false }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2); + }, 200); + + socket2.on("a", () => { + clearTimeout(timeout); + fail(done, io, new Error("not"), socket1, socket2); + }); + socket1.on("a", () => { + clearTimeout(timeout); + success(done, io, socket1, socket2); + }); + socket1.emit("join", "woot"); + socket1.emit("emit", "woot"); + + io.on("connection", socket => { + socket.on("join", (room, fn) => { + socket.join(room); + fn && fn(); + }); + + socket.on("emit", room => { + io.in(room).emit("a"); + }); + }); + }); + + it("emits to rooms avoiding dupes", done => { + const io = new Server(0); + const socket1 = createClient(io, "/", { multiplex: false }); + const socket2 = createClient(io, "/", { multiplex: false }); + + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2); + }, 200); + + const partialDone = createPartialDone(2, err => { + clearTimeout(timeout); + if (err) return fail(done, io, err, socket1, socket2); + success(done, io, socket1, socket2); + }); + + socket2.on("a", () => { + clearTimeout(timeout); + fail(done, io, new Error("not"), socket1, socket2); + }); + socket1.on("a", partialDone); + socket2.on("b", partialDone); + + socket1.emit("join", "woot"); + socket1.emit("join", "test"); + socket2.emit("join", "third", () => { + socket2.emit("emit"); + }); + + io.on("connection", socket => { + socket.on("join", (room, fn) => { + socket.join(room); + fn && fn(); + }); + + socket.on("emit", () => { + io.in("woot").in("test").emit("a"); + io.in("third").emit("b"); + }); + }); + }); + + it("broadcasts to rooms", done => { + const io = new Server(0); + const socket1 = createClient(io, "/", { multiplex: false }); + const socket2 = createClient(io, "/", { multiplex: false }); + const socket3 = createClient(io, "/", { multiplex: false }); + + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2, socket3); + }, 200); + + const partialDone = createPartialDone(2, err => { + clearTimeout(timeout); + if (err) return fail(done, io, err, socket1, socket2, socket3); + success(done, io, socket1, socket2); + }); + + socket1.emit("join", "woot"); + socket2.emit("join", "test"); + socket3.emit("join", "test", () => { + socket3.emit("broadcast"); + }); + + socket1.on("a", () => { + clearTimeout(timeout); + fail(done, io, new Error("not"), socket1, socket2, socket3); + }); + socket2.on("a", () => { + partialDone(); + }); + socket3.on("a", () => { + clearTimeout(timeout); + fail(done, io, new Error("not"), socket1, socket2, socket3); + }); + socket3.on("b", () => { + partialDone(); + }); + + io.on("connection", socket => { + socket.on("join", (room, fn) => { + socket.join(room); + fn && fn(); + }); + + socket.on("broadcast", () => { + socket.broadcast.to("test").emit("a"); + socket.emit("b"); + }); + }); + }); + + it("broadcasts binary data to rooms", done => { + const io = new Server(0); + const socket1 = createClient(io, "/", { multiplex: false }); + const socket2 = createClient(io, "/", { multiplex: false }); + const socket3 = createClient(io, "/", { multiplex: false }); + + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2, socket3); + }, 200); + + const partialDone = createPartialDone(2, err => { + clearTimeout(timeout); + if (err) return fail(done, io, err, socket1, socket2, socket3); + success(done, io, socket1, socket2); + }); + + socket1.emit("join", "woot"); + socket2.emit("join", "test"); + socket3.emit("join", "test", () => { + socket3.emit("broadcast"); + }); + + socket1.on("bin", data => { + clearTimeout(timeout); + fail(done, io, new Error("got bin in socket1"), socket1, socket2, socket3); + }); + socket2.on("bin", data => { + try { + expect(Buffer.isBuffer(data)).toBe(true); + partialDone(); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, socket1, socket2, socket3); + } + }); + socket2.on("bin2", data => { + clearTimeout(timeout); + fail(done, io, new Error("socket2 got bin2"), socket1, socket2, socket3); + }); + socket3.on("bin", data => { + clearTimeout(timeout); + fail(done, io, new Error("socket3 got bin"), socket1, socket2, socket3); + }); + socket3.on("bin2", data => { + try { + expect(Buffer.isBuffer(data)).toBe(true); + partialDone(); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, socket1, socket2, socket3); + } + }); + + io.on("connection", socket => { + socket.on("join", (room, fn) => { + socket.join(room); + fn && fn(); + }); + socket.on("broadcast", () => { + socket.broadcast.to("test").emit("bin", Buffer.alloc(5)); + socket.emit("bin2", Buffer.alloc(5)); + }); + }); + }); + + it("keeps track of rooms", done => { + const io = new Server(0); + const socket = createClient(io); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + clearTimeout(timeout); + try { + s.join("a"); + expect(s.rooms).toStrictEqual(new Set([s.id, "a"])); + s.join("b"); + expect(s.rooms).toStrictEqual(new Set([s.id, "a", "b"])); + s.join("c"); + expect(s.rooms).toStrictEqual(new Set([s.id, "a", "b", "c"])); + s.leave("b"); + expect(s.rooms).toStrictEqual(new Set([s.id, "a", "c"])); + (s as any).leaveAll(); + expect(s.rooms.size).toBe(0); + + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("deletes empty rooms", done => { + const io = new Server(0); + const socket = createClient(io); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + io.on("connection", s => { + clearTimeout(timeout); + try { + s.join("a"); + expect(s.nsp.adapter.rooms.has("a")).toBe(true); + s.leave("a"); + expect(s.nsp.adapter.rooms.has("a")).toBe(false); + + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("should properly cleanup left rooms", done => { + const io = new Server(0); + const socket = createClient(io); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + io.on("connection", s => { + clearTimeout(timeout); + try { + s.join("a"); + expect(s.rooms).toStrictEqual(new Set([s.id, "a"])); + s.join("b"); + expect(s.rooms).toStrictEqual(new Set([s.id, "a", "b"])); + s.leave("unknown"); + expect(s.rooms).toStrictEqual(new Set([s.id, "a", "b"])); + (s as any).leaveAll(); + expect(s.rooms.size).toBe(0); + + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("allows to join several rooms at once", done => { + const io = new Server(0); + const socket = createClient(io); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + clearTimeout(timeout); + try { + s.join(["a", "b", "c"]); + + expect(s.rooms).toStrictEqual(new Set([s.id, "a", "b", "c"])); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("should exclude specific sockets when broadcasting", done => { + const io = new Server(0); + const socket1 = createClient(io, "/", { multiplex: false }); + const socket2 = createClient(io, "/", { multiplex: false }); + const socket3 = createClient(io, "/", { multiplex: false }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2, socket3); + }, 200); + + socket2.on("a", () => { + clearTimeout(timeout); + fail(done, io, new Error("socket2 got a"), socket1, socket2, socket3); + }); + socket3.on("a", () => { + clearTimeout(timeout); + fail(done, io, new Error("socket3 got a"), socket1, socket2, socket3); + }); + socket1.on("a", () => { + clearTimeout(timeout); + success(done, io, socket1, socket2, socket3); + }); + + io.on("connection", socket => { + socket.on("exclude", id => { + socket.broadcast.except(id).emit("a"); + }); + }); + + socket2.on("connect", () => { + socket3.emit("exclude", socket2.id); + }); + }); + + it("should exclude a specific room when broadcasting", done => { + const io = new Server(0); + const socket1 = createClient(io, "/", { multiplex: false }); + const socket2 = createClient(io, "/", { multiplex: false }); + const socket3 = createClient(io, "/", { multiplex: false }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2, socket3); + }, 200); + + socket2.on("a", () => { + clearTimeout(timeout); + fail(done, io, new Error("socket2 got a"), socket1, socket2, socket3); + }); + socket3.on("a", () => { + clearTimeout(timeout); + fail(done, io, new Error("socket3 got a"), socket1, socket2, socket3); + }); + socket1.on("a", () => { + clearTimeout(timeout); + success(done, io, socket1, socket2, socket3); + }); + + io.on("connection", socket => { + socket.on("join", (room, cb) => { + socket.join(room); + cb(); + }); + socket.on("broadcast", () => { + socket.broadcast.except("room1").emit("a"); + }); + }); + + socket2.emit("join", "room1", () => { + socket3.emit("broadcast"); + }); + }); + + it("should return an immutable broadcast operator", done => { + const io = new Server(0); + const clientSocket = createClient(io); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 200); + + io.on("connection", socket => { + clearTimeout(timeout); + try { + const operator = socket.local.compress(false).to(["room1", "room2"]).except("room3"); + operator.compress(true).emit("hello"); + operator.volatile.emit("hello"); + operator.to("room4").emit("hello"); + operator.except("room5").emit("hello"); + socket.emit("hello"); + socket.to("room6").emit("hello"); + // @ts-ignore + expect(operator.rooms).toStrictEqual(new Set(["room1", "room2"])); + // @ts-ignore + expect(operator.rooms.has("room4")).toBeFalsy(); + // @ts-ignore + expect(operator.rooms.has("room5")).toBeFalsy(); + // @ts-ignore + expect(operator.rooms.has("room6")).toBeFalsy(); + // @ts-ignore + expect(operator.exceptRooms.has("room3")).toBe(true); + // @ts-ignore + expect(operator.flags).toStrictEqual({ local: true, compress: false }); + + success(done, io, clientSocket); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + }); + + it("should broadcast and expect multiple acknowledgements", done => { + const io = new Server(0); + const socket1 = createClient(io, "/", { multiplex: false }); + const socket2 = createClient(io, "/", { multiplex: false }); + const socket3 = createClient(io, "/", { multiplex: false }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2, socket3); + }, 3000); + + socket1.on("some event", cb => { + cb(1); + }); + + socket2.on("some event", cb => { + cb(2); + }); + + socket3.on("some event", cb => { + cb(3); + }); + + Promise.all([waitFor(socket1, "connect"), waitFor(socket2, "connect"), waitFor(socket3, "connect")]).then(() => { + io.timeout(2000).emit("some event", (err, responses) => { + clearTimeout(timeout); + try { + expect(err).toBe(null); + expect(responses.length).toBe(3); + expect(responses).toContain(1); + expect(responses).toContain(2); + expect(responses).toContain(3); + + success(done, io, socket1, socket2, socket3); + } catch (err) { + fail(done, io, err, socket1, socket2, socket3); + } + }); + }); + }); + + it("should fail when a client does not acknowledge the event in the given delay", done => { + const io = new Server(0); + const socket1 = createClient(io, "/", { multiplex: false }); + const socket2 = createClient(io, "/", { multiplex: false }); + const socket3 = createClient(io, "/", { multiplex: false }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2, socket3); + }, 300); + + socket1.on("some event", cb => { + cb(1); + }); + + socket2.on("some event", cb => { + cb(2); + }); + + socket3.on("some event", () => { + // timeout + }); + + Promise.all([waitFor(socket1, "connect"), waitFor(socket2, "connect"), waitFor(socket3, "connect")]).then(() => { + io.timeout(200).emit("some event", (err, responses) => { + clearTimeout(timeout); + try { + expect(err).toBeInstanceOf(Error); + expect(responses.length).toBe(2); + expect(responses).toContain(1); + expect(responses).toContain(2); + + success(done, io, socket1, socket2, socket3); + success(done, io, socket1, socket2, socket3); + } catch (err) { + fail(done, io, err, socket1, socket2, socket3); + } + }); + }); + }); + + it("should broadcast and expect multiple acknowledgements (promise)", done => { + const io = new Server(0); + const socket1 = createClient(io, "/", { multiplex: false }); + const socket2 = createClient(io, "/", { multiplex: false }); + const socket3 = createClient(io, "/", { multiplex: false }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2, socket3); + }, 3000); + + socket1.on("some event", cb => { + cb(1); + }); + + socket2.on("some event", cb => { + cb(2); + }); + + socket3.on("some event", cb => { + cb(3); + }); + + Promise.all([waitFor(socket1, "connect"), waitFor(socket2, "connect"), waitFor(socket3, "connect")]).then( + async () => { + try { + const responses = await io.timeout(2000).emitWithAck("some event"); + clearTimeout(timeout); + expect(responses).toContain(1); + expect(responses).toContain(2); + expect(responses).toContain(3); + + success(done, io, socket1, socket2, socket3); + } catch (err) { + fail(done, io, err, socket1, socket2, socket3); + } + }, + ); + }); + + it("should fail when a client does not acknowledge the event in the given delay (promise)", done => { + const io = new Server(0); + const socket1 = createClient(io, "/", { multiplex: false }); + const socket2 = createClient(io, "/", { multiplex: false }); + const socket3 = createClient(io, "/", { multiplex: false }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2, socket3); + }, 300); + + socket1.on("some event", cb => { + cb(1); + }); + + socket2.on("some event", cb => { + cb(2); + }); + + socket3.on("some event", () => { + // timeout + }); + + Promise.all([waitFor(socket1, "connect"), waitFor(socket2, "connect"), waitFor(socket3, "connect")]).then( + async () => { + try { + await io.timeout(200).emitWithAck("some event"); + clearTimeout(timeout); + fail(done, io, new Error("should not happen"), socket1, socket2, socket3); + } catch (err) { + clearTimeout(timeout); + try { + expect(err).toBeInstanceOf(Error); + // @ts-ignore + expect(err.responses.length).toBe(2); + // @ts-ignore + expect(err.responses).toContain(1); + // @ts-ignore + expect(err.responses).toContain(2); + + success(done, io, socket1, socket2, socket3); + } catch (err) { + fail(done, io, err, socket1, socket2, socket3); + } + } + }, + ); + }); + + it("should broadcast and return if the packet is sent to 0 client", done => { + const io = new Server(0); + const socket1 = createClient(io, "/", { multiplex: false }); + const socket2 = createClient(io, "/", { multiplex: false }); + const socket3 = createClient(io, "/", { multiplex: false }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2, socket3); + }, 300); + + socket1.on("some event", () => { + done(new Error("should not happen")); + }); + + socket2.on("some event", () => { + done(new Error("should not happen")); + }); + + socket3.on("some event", () => { + done(new Error("should not happen")); + }); + + io.to("room123") + .timeout(200) + .emit("some event", (err, responses) => { + clearTimeout(timeout); + try { + expect(err).toBe(null); + expect(responses.length).toBe(0); + + success(done, io, socket1, socket2, socket3); + } catch (err) { + fail(done, io, err, socket1, socket2, socket3); + } + }); + }); + + it.skip("should precompute the WebSocket frame when broadcasting", done => { + const io = new Server(0); + const socket = createClient(io, "/chat", { + transports: ["websocket"], + }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + const partialDone = createPartialDone(2, err => { + clearTimeout(timeout); + if (err) fail(done, io, err, socket); + else success(done, io, socket); + }); + + io.of("/chat").on("connection", s => { + s.conn.once("packetCreate", packet => { + try { + expect(packet.options.wsPreEncodedFrame).toBeInstanceOf(Array); + partialDone(); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, socket); + } + }); + io.of("/chat").compress(false).emit("woot", "hi"); + }); + + socket.on("woot", partialDone); + }); +}); diff --git a/test/js/third_party/socket.io/socket.io-middleware.test.ts b/test/js/third_party/socket.io/socket.io-middleware.test.ts new file mode 100644 index 000000000..637d0e08f --- /dev/null +++ b/test/js/third_party/socket.io/socket.io-middleware.test.ts @@ -0,0 +1,299 @@ +import { Server, Socket } from "socket.io"; +import { describe, it, expect } from "bun:test"; + +import { success, fail, createClient, createPartialDone } from "./support/util.ts"; + +describe("middleware", () => { + it("should call functions", done => { + const io = new Server(0); + let timeout: Timer; + + let run = 0; + io.use((socket, next) => { + try { + expect(socket).toBeInstanceOf(Socket); + run++; + next(); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err); + } + }); + io.use((socket, next) => { + try { + expect(socket).toBeInstanceOf(Socket); + run++; + next(); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err); + } + }); + + const socket = createClient(io); + timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + socket.on("connect", () => { + try { + clearTimeout(timeout); + expect(run).toBe(2); + + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("should pass errors", done => { + const io = new Server(0); + let timeout: Timer; + let socket; + io.use((socket, next) => { + next(new Error("Authentication error")); + }); + io.use((socket, next) => { + clearTimeout(timeout); + fail(done, io, new Error("nope"), socket); + }); + + socket = createClient(io); + timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + socket.on("connect", () => { + done(new Error("nope")); + }); + socket.on("connect_error", err => { + try { + clearTimeout(timeout); + expect(err.message).toBe("Authentication error"); + + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("should pass an object", done => { + const io = new Server(0); + + io.use((socket, next) => { + const err = new Error("Authentication error"); + // @ts-ignore + err.data = { a: "b", c: 3 }; + next(err); + }); + + const socket = createClient(io); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + socket.on("connect", () => { + clearTimeout(timeout); + fail(done, io, new Error("nope"), socket); + }); + + socket.on("connect_error", err => { + try { + clearTimeout(timeout); + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe("Authentication error"); + // @ts-ignore + expect(err.data).toStrictEqual({ a: "b", c: 3 }); + + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("should only call connection after fns", done => { + const io = new Server(0); + + io.use((socket: any, next) => { + socket.name = "guillermo"; + next(); + }); + + const clientSocket = createClient(io); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 200); + + io.on("connection", socket => { + try { + clearTimeout(timeout); + expect((socket as any).name).toBe("guillermo"); + + success(done, io, clientSocket); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + }); + + it("should only call connection after (lengthy) fns", done => { + const io = new Server(0); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 2000); + + let authenticated = false; + + io.use((socket, next) => { + setTimeout(() => { + authenticated = true; + next(); + }, 300); + }); + + const socket = createClient(io); + socket.on("connect", () => { + try { + clearTimeout(timeout); + expect(authenticated).toBe(true); + + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("should be ignored if socket gets closed", done => { + const io = new Server(0); + let timeout: Timer; + let socket; + io.use((s, next) => { + socket.io.engine.close(); + s.client.conn.on("close", () => { + process.nextTick(next); + setTimeout(() => { + clearTimeout(timeout); + success(done, io, socket); + }, 50); + }); + }); + + socket = createClient(io); + timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", socket => { + clearTimeout(timeout); + fail(done, io, new Error("should not fire"), socket); + }); + }); + + it("should call functions in expected order", done => { + const io = new Server(0); + + const result: number[] = []; + + io.use(() => { + fail(done, io, new Error("should not fire")); + }); + io.of("/chat").use((socket, next) => { + result.push(1); + setTimeout(next, 50); + }); + io.of("/chat").use((socket, next) => { + result.push(2); + setTimeout(next, 50); + }); + io.of("/chat").use((socket, next) => { + result.push(3); + setTimeout(next, 50); + }); + + const chat = createClient(io, "/chat"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), chat); + }, 1000); + + chat.on("connect", () => { + clearTimeout(timeout); + try { + expect(result).toStrictEqual([1, 2, 3]); + + success(done, io, chat); + } catch (err) { + fail(done, io, err, chat); + } + }); + }); + + it("should disable the merge of handshake packets", done => { + const io = new Server(0); + io.use((socket, next) => { + next(); + }); + + const socket = createClient(io); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + socket.on("connect", () => { + clearTimeout(timeout); + success(done, io, socket); + }); + }); + + it("should work with a custom namespace", done => { + const io = new Server(0); + const socket1 = createClient(io, "/"); + const socket2 = createClient(io, "/chat"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2); + }, 200); + + const partialDone = createPartialDone(2, () => { + clearTimeout(timeout); + success(done, io, socket1, socket2); + }); + + io.of("/chat").use((socket, next) => { + next(); + }); + + socket1.on("connect", partialDone); + socket2.on("connect", partialDone); + }); + + it("should only set `connected` to true after the middleware execution", done => { + const io = new Server(0); + const clientSocket = createClient(io, "/"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 200); + io.use((socket, next) => { + try { + expect(socket.connected).toBe(false); + expect(socket.disconnected).toBe(true); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, clientSocket); + } + next(); + }); + + io.on("connection", socket => { + try { + clearTimeout(timeout); + expect(socket.connected).toBe(true); + expect(socket.disconnected).toBe(false); + + success(done, io, clientSocket); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + }); +}); diff --git a/test/js/third_party/socket.io/socket.io-namespaces.test.ts b/test/js/third_party/socket.io/socket.io-namespaces.test.ts new file mode 100644 index 000000000..87df24a57 --- /dev/null +++ b/test/js/third_party/socket.io/socket.io-namespaces.test.ts @@ -0,0 +1,925 @@ +import { describe, it, expect } from "bun:test"; +import type { SocketId } from "socket.io-adapter"; +import { Server, Namespace, Socket } from "socket.io"; +import { success, fail, createClient, createPartialDone } from "./support/util.ts"; + +describe("namespaces", () => { + it("should be accessible through .sockets", done => { + const io = new Server(); + expect(io.sockets).toBeInstanceOf(Namespace); + done(); + }); + + it("should be aliased", done => { + const io = new Server(); + expect(typeof io.use).toBe("function"); + expect(typeof io.to).toBe("function"); + expect(typeof io["in"]).toBe("function"); + expect(typeof io.emit).toBe("function"); + expect(typeof io.send).toBe("function"); + expect(typeof io.write).toBe("function"); + expect(typeof io.allSockets).toBe("function"); + expect(typeof io.compress).toBe("function"); + done(); + }); + + it("should return an immutable broadcast operator", done => { + const io = new Server(); + const operator = io.local.to(["room1", "room2"]).except("room3"); + operator.compress(true).emit("hello"); + operator.volatile.emit("hello"); + operator.to("room4").emit("hello"); + operator.except("room5").emit("hello"); + io.to("room6").emit("hello"); + // @ts-ignore + expect(operator.rooms).toStrictEqual(new Set(["room1", "room2"])); + // @ts-ignore + expect(operator.exceptRooms).toStrictEqual(new Set(["room3"])); + // @ts-ignore + expect(operator.flags).toStrictEqual({ local: true }); + + done(); + }); + + it("should automatically connect", done => { + const io = new Server(0); + const socket = createClient(io); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + socket.on("connect", () => { + clearTimeout(timeout); + success(done, io, socket); + }); + }); + + it("should fire a `connection` event", done => { + const io = new Server(0); + const socket = createClient(io); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + io.on("connection", s => { + clearTimeout(timeout); + expect(s).toBeInstanceOf(Socket); + success(done, io, socket); + }); + }); + + it("should fire a `connect` event", done => { + const io = new Server(0); + const socket = createClient(io); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + io.on("connect", s => { + clearTimeout(timeout); + expect(s).toBeInstanceOf(Socket); + success(done, io, socket); + }); + }); + + it("should work with many sockets", done => { + const io = new Server(0); + io.of("/chat"); + io.of("/news"); + const chat = createClient(io, "/chat"); + const news = createClient(io, "/news"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), chat, news); + }, 300); + + let total = 2; + function _success() { + clearTimeout(timeout); + success(done, io, chat, news); + } + + chat.on("connect", () => { + --total || _success(); + }); + news.on("connect", () => { + --total || _success(); + }); + }); + + it('should be able to equivalently start with "" or "/" on server', done => { + const io = new Server(0); + const c1 = createClient(io, "/"); + const c2 = createClient(io, "/abc"); + + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), c1, c2); + }, 300); + + let total = 2; + function _success() { + clearTimeout(timeout); + success(done, io, c1, c2); + } + + io.of("").on("connection", () => { + --total || _success(); + }); + io.of("abc").on("connection", () => { + --total || _success(); + }); + }); + + it('should be equivalent for "" and "/" on client', done => { + const io = new Server(0); + const c1 = createClient(io, ""); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), c1); + }, 300); + io.of("/").on("connection", () => { + clearTimeout(timeout); + success(done, io, c1); + }); + }); + + it("should work with `of` and many sockets", done => { + const io = new Server(0); + const chat = createClient(io, "/chat"); + const news = createClient(io, "/news"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), chat, news); + }, 300); + + let total = 2; + function _success() { + clearTimeout(timeout); + success(done, io, chat, news); + } + io.of("/news").on("connection", socket => { + try { + expect(socket).toBeInstanceOf(Socket); + --total || _success(); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, chat, news); + } + }); + io.of("/news").on("connection", socket => { + try { + expect(socket).toBeInstanceOf(Socket); + --total || _success(); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, chat, news); + } + }); + }); + + it("should work with `of` second param", done => { + const io = new Server(0); + const chat = createClient(io, "/chat"); + const news = createClient(io, "/news"); + + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), chat, news); + }, 300); + + let total = 2; + function _success() { + clearTimeout(timeout); + success(done, io, chat, news); + } + io.of("/news", socket => { + try { + expect(socket).toBeInstanceOf(Socket); + --total || _success(); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, chat, news); + } + }); + io.of("/news", socket => { + try { + expect(socket).toBeInstanceOf(Socket); + --total || _success(); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, chat, news); + } + }); + }); + + it("should disconnect upon transport disconnection", done => { + const io = new Server(0); + const chat = createClient(io, "/chat"); + const news = createClient(io, "/news"); + + let total = 2; + let totald = 2; + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), chat, news); + }, 300); + + function _success() { + clearTimeout(timeout); + success(done, io, chat, news); + } + + function close() { + s.disconnect(true); + } + + let s: Socket; + io.of("/news", socket => { + socket.on("disconnect", reason => { + --totald || _success(); + }); + --total || close(); + }); + io.of("/chat", socket => { + s = socket; + socket.on("disconnect", reason => { + --totald || _success(); + }); + --total || close(); + }); + }); + + it("should fire a `disconnecting` event just before leaving all rooms", done => { + const io = new Server(0); + const socket = createClient(io); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + io.on("connection", s => { + s.join("a"); + // FIXME not sure why process.nextTick() is needed here + process.nextTick(() => s.disconnect()); + + let total = 2; + + function _success() { + clearTimeout(timeout); + success(done, io, socket); + } + + s.on("disconnecting", reason => { + try { + expect(s.rooms).toStrictEqual(new Set([s.id, "a"])); + total--; + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, socket); + } + }); + + s.on("disconnect", reason => { + try { + expect(s.rooms.size).toBe(0); + --total || _success(); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, socket); + } + }); + }); + }); + + it("should return error connecting to non-existent namespace", done => { + const io = new Server(0); + const socket = createClient(io, "/doesnotexist"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + socket.on("connect_error", err => { + clearTimeout(timeout); + try { + expect(err.message).toBe("Invalid namespace"); + success(done, io); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("should not reuse same-namespace connections", done => { + const io = new Server(0); + const clientSocket1 = createClient(io); + const clientSocket2 = createClient(io); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket1, clientSocket2); + }, 300); + + let connections = 0; + io.on("connection", () => { + connections++; + if (connections === 2) { + clearTimeout(timeout); + success(done, io, clientSocket1, clientSocket2); + } + }); + }); + + it("should find all clients in a namespace", done => { + const io = new Server(0); + const chatSids: string[] = []; + let otherSid: SocketId | null = null; + + const c1 = createClient(io, "/chat"); + const c2 = createClient(io, "/chat", { forceNew: true }); + const c3 = createClient(io, "/other", { forceNew: true }); + + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), c1, c2, c3); + }, 300); + + let total = 3; + io.of("/chat").on("connection", socket => { + chatSids.push(socket.id); + --total || getSockets(); + }); + io.of("/other").on("connection", socket => { + otherSid = socket.id; + --total || getSockets(); + }); + + async function getSockets() { + const sids = await io.of("/chat").allSockets(); + clearTimeout(timeout); + try { + expect(sids).toStrictEqual(new Set([chatSids[0], chatSids[1]])); + expect(sids).not.toContain(otherSid); + success(done, io, c1, c2, c3); + } catch (err) { + fail(done, io, err, c1, c2, c3); + } + } + }); + + it("should find all clients in a namespace room", done => { + const io = new Server(0); + let chatFooSid: SocketId | null = null; + let chatBarSid: SocketId | null = null; + let otherSid: SocketId | null = null; + + const c1 = createClient(io, "/chat"); + const c2 = createClient(io, "/chat", { forceNew: true }); + const c3 = createClient(io, "/other", { forceNew: true }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), c1, c2, c3); + }, 300); + + let chatIndex = 0; + let total = 3; + io.of("/chat").on("connection", socket => { + if (chatIndex++) { + socket.join("foo"); + chatFooSid = socket.id; + --total || getSockets(); + } else { + socket.join("bar"); + chatBarSid = socket.id; + --total || getSockets(); + } + }); + io.of("/other").on("connection", socket => { + socket.join("foo"); + otherSid = socket.id; + --total || getSockets(); + }); + + async function getSockets() { + const sids = await io.of("/chat").in("foo").allSockets(); + clearTimeout(timeout); + try { + expect(sids).toStrictEqual(new Set([chatFooSid])); + expect(sids).not.toContain(chatBarSid); + expect(sids).not.toContain(otherSid); + success(done, io, c1, c2, c3); + } catch (err) { + fail(done, io, err, c1, c2, c3); + } + } + }); + + it("should find all clients across namespace rooms", done => { + const io = new Server(0); + let chatFooSid: SocketId | null = null; + let chatBarSid: SocketId | null = null; + let otherSid: SocketId | null = null; + + const c1 = createClient(io, "/chat"); + const c2 = createClient(io, "/chat", { forceNew: true }); + const c3 = createClient(io, "/other", { forceNew: true }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), c1, c2, c3); + }, 300); + + let chatIndex = 0; + let total = 3; + io.of("/chat").on("connection", socket => { + if (chatIndex++) { + socket.join("foo"); + chatFooSid = socket.id; + --total || getSockets(); + } else { + socket.join("bar"); + chatBarSid = socket.id; + --total || getSockets(); + } + }); + io.of("/other").on("connection", socket => { + socket.join("foo"); + otherSid = socket.id; + --total || getSockets(); + }); + + async function getSockets() { + const sids = await io.of("/chat").allSockets(); + clearTimeout(timeout); + try { + expect(sids).toStrictEqual(new Set([chatFooSid, chatBarSid])); + expect(sids).not.toContain(otherSid); + success(done, io, c1, c2, c3); + } catch (err) { + fail(done, io, err, c1, c2, c3); + } + } + }); + + it("should not emit volatile event after regular event", done => { + const io = new Server(0); + + let counter = 0; + io.of("/chat").on("connection", s => { + // Wait to make sure there are no packets being sent for opening the connection + setTimeout(() => { + io.of("/chat").emit("ev", "data"); + io.of("/chat").volatile.emit("ev", "data"); + }, 50); + }); + + const socket = createClient(io, "/chat"); + socket.on("ev", () => { + counter++; + }); + + setTimeout(() => { + try { + expect(counter).toBe(1); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }, 300); + }); + + it("should emit volatile event", done => { + const io = new Server(0); + + let counter = 0; + io.of("/chat").on("connection", s => { + // Wait to make sure there are no packets being sent for opening the connection + setTimeout(() => { + io.of("/chat").volatile.emit("ev", "data"); + }, 100); + }); + + const socket = createClient(io, "/chat"); + socket.on("ev", () => { + counter++; + }); + + setTimeout(() => { + try { + expect(counter).toBe(1); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }, 300); + }); + + it("should enable compression by default", done => { + const io = new Server(0); + const socket = createClient(io, "/chat"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + io.of("/chat").on("connection", s => { + s.conn.once("packetCreate", packet => { + clearTimeout(timeout); + try { + expect(packet.options.compress).toBe(true); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + io.of("/chat").emit("woot", "hi"); + }); + }); + + it("should disable compression", done => { + const io = new Server(0); + const socket = createClient(io, "/chat"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + io.of("/chat").on("connection", s => { + s.conn.once("packetCreate", packet => { + clearTimeout(timeout); + try { + expect(packet.options.compress).toBe(false); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + io.of("/chat").compress(false).emit("woot", "hi"); + }); + }); + + it("should throw on reserved event", () => { + const io = new Server(); + + expect(() => io.emit("connect")).toThrow(/"connect" is a reserved event name/); + }); + + it("should close a client without namespace", done => { + const io = new Server(0, { + connectTimeout: 10, + }); + + const socket = createClient(io); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + // @ts-ignore + socket.io.engine.write = () => {}; // prevent the client from sending a CONNECT packet + + socket.on("disconnect", () => { + clearTimeout(timeout); + success(done, io, socket); + }); + }); + + it("should exclude a specific socket when emitting", done => { + const io = new Server(0); + + const socket1 = createClient(io, "/"); + const socket2 = createClient(io, "/"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2); + }, 300); + + socket2.on("a", () => { + clearTimeout(timeout); + fail(done, io, new Error("should not happen"), socket1, socket2); + }); + socket1.on("a", () => { + clearTimeout(timeout); + success(done, io, socket1, socket2); + }); + + socket2.on("connect", () => { + io.except(socket2.id).emit("a"); + }); + }); + + it("should exclude a specific socket when emitting (in a namespace)", done => { + const io = new Server(0); + + const nsp = io.of("/nsp"); + + const socket1 = createClient(io, "/nsp"); + const socket2 = createClient(io, "/nsp"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2); + }, 300); + + socket2.on("a", () => { + clearTimeout(timeout); + fail(done, io, new Error("should not happen"), socket1, socket2); + }); + socket1.on("a", () => { + clearTimeout(timeout); + success(done, io, socket1, socket2); + }); + + socket2.on("connect", () => { + nsp.except(socket2.id).emit("a"); + }); + }); + + it("should exclude a specific room when emitting", done => { + const io = new Server(0); + + const nsp = io.of("/nsp"); + + const socket1 = createClient(io, "/nsp"); + const socket2 = createClient(io, "/nsp"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2); + }, 300); + + socket1.on("a", () => { + clearTimeout(timeout); + success(done, io, socket1, socket2); + }); + socket2.on("a", () => { + clearTimeout(timeout); + fail(done, io, new Error("should not happen"), socket1, socket2); + }); + + nsp.on("connection", socket => { + socket.on("broadcast", () => { + socket.join("room1"); + nsp.except("room1").emit("a"); + }); + }); + + socket2.emit("broadcast"); + }); + + it("should emit an 'new_namespace' event", done => { + const io = new Server(); + + io.on("new_namespace", namespace => { + expect(namespace.name).toBe("/nsp"); + done(); + }); + + io.of("/nsp"); + }); + + it("should not clean up a non-dynamic namespace", done => { + const io = new Server(0, { cleanupEmptyChildNamespaces: true }); + const c1 = createClient(io, "/chat"); + + c1.on("connect", () => { + c1.disconnect(); + + // Give it some time to disconnect the client + setTimeout(() => { + try { + expect(io._nsps.has("/chat")).toBe(true); + expect(io._nsps.get("/chat")!.sockets.size).toBe(0); + success(done, io); + } catch (err) { + fail(done, io, err); + } + }, 100); + }); + + io.of("/chat"); + }); + + describe("dynamic namespaces", () => { + it("should allow connections to dynamic namespaces with a regex", done => { + const io = new Server(0); + const socket = createClient(io, "/dynamic-101"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + const partialDone = createPartialDone(4, () => { + clearTimeout(timeout); + success(done, io, socket); + }); + + let dynamicNsp = io + .of(/^\/dynamic-\d+$/) + .on("connect", socket => { + try { + expect(socket.nsp.name).toBe("/dynamic-101"); + dynamicNsp.emit("hello", 1, "2", { 3: "4" }); + partialDone(); + } catch (err) { + fail(done, io, err, socket); + } + }) + .use((socket, next) => { + next(); + partialDone(); + }); + socket.on("connect_error", err => { + clearTimeout(timeout); + fail(done, io, err, socket); + }); + socket.on("connect", () => { + partialDone(); + }); + socket.on("hello", (a, b, c) => { + try { + expect(a).toBe(1); + expect(b).toBe("2"); + expect(c).toStrictEqual({ 3: "4" }); + partialDone(); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("should allow connections to dynamic namespaces with a function", done => { + const io = new Server(0); + const socket = createClient(io, "/dynamic-101"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + io.of((name, query, next) => next(null, "/dynamic-101" === name)); + socket.on("connect", () => { + clearTimeout(timeout); + success(done, io, socket); + }); + }); + + it("should disallow connections when no dynamic namespace matches", done => { + const io = new Server(0); + const socket = createClient(io, "/abc"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + io.of(/^\/dynamic-\d+$/); + io.of((name, query, next) => next(null, "/dynamic-101" === name)); + + socket.on("connect_error", err => { + clearTimeout(timeout); + try { + expect(err.message).toBe("Invalid namespace"); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("should emit an 'new_namespace' event for a dynamic namespace", done => { + const io = new Server(0); + io.of(/^\/dynamic-\d+$/); + const socket = createClient(io, "/dynamic-101"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + io.on("new_namespace", namespace => { + clearTimeout(timeout); + try { + expect(namespace.name).toBe("/dynamic-101"); + + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("should handle race conditions with dynamic namespaces (#4136)", done => { + const io = new Server(0); + let timeout: Timer; + const counters = { + connected: 0, + created: 0, + events: 0, + }; + const buffer: Function[] = []; + io.on("new_namespace", namespace => { + counters.created++; + }); + + const handler = () => { + if (++counters.events === 2) { + clearTimeout(timeout); + try { + expect(counters.created).toBe(1); + success(done, io, one, two); + } catch (err) { + fail(done, io, err, one, two); + } + } + }; + + io.of((name, query, next) => { + buffer.push(next); + if (buffer.length === 2) { + buffer.forEach(next => next(null, true)); + } + }).on("connection", socket => { + if (++counters.connected === 2) { + io.of("/dynamic-101").emit("message"); + } + }); + + let one = createClient(io, "/dynamic-101"); + let two = createClient(io, "/dynamic-101"); + timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), one, two); + }, 300); + one.on("message", handler); + two.on("message", handler); + }); + + it("should clean up namespace when cleanupEmptyChildNamespaces is on and there are no more sockets in a namespace", done => { + const io = new Server(0, { cleanupEmptyChildNamespaces: true }); + const c1 = createClient(io, "/dynamic-101"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), c1); + }, 300); + + c1.on("connect", () => { + c1.disconnect(); + + // Give it some time to disconnect and clean up the namespace + setTimeout(() => { + clearTimeout(timeout); + try { + expect(io._nsps.has("/dynamic-101")).toBe(false); + success(done, io); + } catch (err) { + fail(done, io, err, c1); + } + }, 100); + }); + + io.of(/^\/dynamic-\d+$/); + }); + + it("should allow a client to connect to a cleaned up namespace", done => { + const io = new Server(0, { cleanupEmptyChildNamespaces: true }); + const c1 = createClient(io, "/dynamic-101"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), c1); + }, 300); + c1.on("connect", () => { + c1.disconnect(); + + // Give it some time to disconnect and clean up the namespace + setTimeout(() => { + try { + expect(io._nsps.has("/dynamic-101")).toBe(false); + + const c2 = createClient(io, "/dynamic-101"); + + c2.on("connect", () => { + clearTimeout(timeout); + success(done, io, c2); + }); + + c2.on("connect_error", () => { + clearTimeout(timeout); + fail(done, io, new Error("Client got error when connecting to dynamic namespace"), c1); + }); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, c1); + } + }, 100); + }); + + io.of(/^\/dynamic-\d+$/); + }); + + it("should not clean up namespace when cleanupEmptyChildNamespaces is off and there are no more sockets in a namespace", done => { + const io = new Server(0); + const c1 = createClient(io, "/dynamic-101"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), c1); + }, 300); + c1.on("connect", () => { + c1.disconnect(); + + // Give it some time to disconnect and clean up the namespace + setTimeout(() => { + clearTimeout(timeout); + try { + expect(io._nsps.has("/dynamic-101")).toBe(true); + expect(io._nsps.get("/dynamic-101")!.sockets.size).toBe(0); + success(done, io); + } catch (err) { + fail(done, io, err, c1); + } + }, 100); + }); + + io.of(/^\/dynamic-\d+$/); + }); + + it("should attach a child namespace to its parent upon manual creation", done => { + const io = new Server(0); + const parentNamespace = io.of(/^\/dynamic-\d+$/); + const childNamespace = io.of("/dynamic-101"); + + try { + // @ts-ignore + expect(parentNamespace.children.has(childNamespace)).toBe(true); + success(done, io); + } catch (err) { + fail(done, io, err); + } + }); + }); +}); diff --git a/test/js/third_party/socket.io/socket.io-server-attachment.test.ts b/test/js/third_party/socket.io/socket.io-server-attachment.test.ts new file mode 100644 index 000000000..15398cdb1 --- /dev/null +++ b/test/js/third_party/socket.io/socket.io-server-attachment.test.ts @@ -0,0 +1,235 @@ +import { Server } from "socket.io"; +import { createServer } from "http"; +import request from "supertest"; +import { getPort, success, fail } from "./support/util"; +import { describe, it, expect } from "bun:test"; + +describe("server attachment", () => { + describe("http.Server", () => { + const clientVersion = require("socket.io-client/package.json").version; + + const testSource = filename => done => { + const srv = createServer(); + const io = new Server(srv); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 1000); + + request(srv) + .get("/socket.io/" + filename) + .buffer(true) + .end((err, res) => { + clearTimeout(timeout); + if (err) return fail(done, io, err); + try { + expect(res.headers["content-type"]).toBe("application/javascript; charset=utf-8"); + expect(res.headers.etag).toBe('"' + clientVersion + '"'); + expect(res.headers["x-sourcemap"]).toBe(undefined); + expect(res.text).toMatch(/engine\.io/); + expect(res.status).toBe(200); + success(done, io); + } catch (err) { + fail(done, io, err); + } + }); + }; + + const testSourceMap = filename => done => { + const srv = createServer(); + const io = new Server(srv); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + request(srv) + .get("/socket.io/" + filename) + .buffer(true) + .end((err, res) => { + clearTimeout(timeout); + if (err) return fail(done, io, err); + try { + expect(res.headers["content-type"]).toBe("application/json; charset=utf-8"); + expect(res.headers.etag).toBe('"' + clientVersion + '"'); + expect(res.text).toMatch(/engine\.io/); + expect(res.status).toBe(200); + success(done, io); + } catch (err) { + fail(done, io, err); + } + }); + }; + + it("should serve client", testSource("socket.io.js")); + it("should serve client with query string", testSource("socket.io.js?buster=" + Date.now())); + it("should serve source map", testSourceMap("socket.io.js.map")); + it("should serve client (min)", testSource("socket.io.min.js")); + + it("should serve source map (min)", testSourceMap("socket.io.min.js.map")); + + it.skip("should serve client (gzip)", done => { + const srv = createServer(); + const io = new Server(srv); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + request(srv) + .get("/socket.io/socket.io.js") + .set("accept-encoding", "gzip,br,deflate") + .buffer(true) + .end((err, res) => { + clearTimeout(timeout); + if (err) return fail(done, io, err); + try { + expect(res.headers["content-encoding"]).toBe("gzip"); + expect(res.status).toBe(200); + success(done, io); + } catch (err) { + fail(done, io, err); + } + }); + }); + + it("should serve bundle with msgpack parser", testSource("socket.io.msgpack.min.js")); + + it("should serve source map for bundle with msgpack parser", testSourceMap("socket.io.msgpack.min.js.map")); + + it("should serve the ESM bundle", testSource("socket.io.esm.min.js")); + + it("should serve the source map for the ESM bundle", testSourceMap("socket.io.esm.min.js.map")); + + it("should handle 304", done => { + const srv = createServer(); + const io = new Server(srv); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + request(srv) + .get("/socket.io/socket.io.js") + .set("If-None-Match", '"' + clientVersion + '"') + .end((err, res) => { + try { + clearTimeout(timeout); + if (err) return done(err); + expect(res.statusCode).toBe(304); + success(done, io); + } catch (err) { + fail(done, io, err); + } + }); + }); + + it("should handle 304", done => { + const srv = createServer(); + const io = new Server(srv); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + request(srv) + .get("/socket.io/socket.io.js") + .set("If-None-Match", 'W/"' + clientVersion + '"') + .end((err, res) => { + try { + clearTimeout(timeout); + if (err) return done(err); + expect(res.statusCode).toBe(304); + success(done, io); + } catch (err) { + fail(done, io, err); + } + }); + }); + + it("should not serve static files", done => { + const srv = createServer(); + const io = new Server(srv, { serveClient: false }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + + request(srv) + .get("/socket.io/socket.io.js") + .expect(400, err => { + clearTimeout(timeout); + if (err) return fail(done, io, err); + success(done, io); + }); + }); + + it("should work with #attach", done => { + const srv = createServer((req, res) => { + res.writeHead(404); + res.end(); + }); + const sockets = new Server(); + const timeout = setTimeout(() => { + fail(done, sockets, new Error("timeout")); + }, 200); + sockets.attach(srv); + request(srv) + .get("/socket.io/socket.io.js") + .end((err, res) => { + try { + clearTimeout(timeout); + if (err) return done(err); + expect(res.statusCode).toBe(200); + success(done, sockets); + } catch (err) { + fail(done, sockets, err); + } + }); + }); + + it("should work with #attach (and merge options)", done => { + const srv = createServer((req, res) => { + res.writeHead(404); + res.end(); + }); + const server = new Server({ + pingTimeout: 6000, + }); + try { + server.attach(srv, { + pingInterval: 24000, + }); + // @ts-ignore + expect(server.eio.opts.pingTimeout).toBe(6000); + // @ts-ignore + expect(server.eio.opts.pingInterval).toBe(24000); + success(done, server); + } catch (err) { + fail(done, server, err); + } + }); + }); + + describe("port", () => { + it("should be bound", done => { + const io = new Server(0); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + + request(`http://localhost:${getPort(io)}`) + .get("/socket.io/socket.io.js") + .expect(200, err => { + clearTimeout(timeout); + if (err) return fail(done, io, err); + success(done, io); + }); + }); + + it("with listen", done => { + const io = new Server().listen(0); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout")); + }, 200); + + request(`http://localhost:${getPort(io)}`) + .get("/socket.io/socket.io.js") + .expect(200, err => { + clearTimeout(timeout); + if (err) return fail(done, io, err); + success(done, io); + }); + }); + }); +}); diff --git a/test/js/third_party/socket.io/socket.io-socket-middleware.test.ts b/test/js/third_party/socket.io/socket.io-socket-middleware.test.ts new file mode 100644 index 000000000..4eea39491 --- /dev/null +++ b/test/js/third_party/socket.io/socket.io-socket-middleware.test.ts @@ -0,0 +1,89 @@ +// TODO: uncomment when Blob bug in isBinary is fixed + +import { Server } from "socket.io"; +import { describe, it, expect } from "bun:test"; + +import { success, fail, createClient } from "./support/util.ts"; + +describe("socket middleware", () => { + it.skip("should call functions", done => { + const io = new Server(0); + const clientSocket = createClient(io, "/", { multiplex: false }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 200); + + clientSocket.emit("join", "woot"); + + let run = 0; + + io.on("connection", socket => { + socket.use((event, next) => { + try { + expect(event).toStrictEqual(["join", "woot"]); + event.unshift("wrap"); + run++; + next(); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, clientSocket); + } + }); + socket.use((event, next) => { + try { + expect(event).toStrictEqual(["wrap", "join", "woot"]); + run++; + next(); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, clientSocket); + } + }); + socket.on("wrap", (data1, data2) => { + try { + clearTimeout(timeout); + expect(data1).toBe("join"); + expect(data2).toBe("woot"); + expect(run).toBe(2); + + success(done, io, clientSocket); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + }); + }); + + it.skip("should pass errors", done => { + const io = new Server(0); + const clientSocket = createClient(io, "/", { multiplex: false }); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 200); + + clientSocket.emit("join", "woot"); + + io.on("connection", socket => { + socket.use((event, next) => { + next(new Error("Authentication error")); + }); + socket.use((event, next) => { + done(new Error("should not happen")); + }); + socket.on("join", () => { + done(new Error("should not happen")); + }); + socket.on("error", err => { + try { + clearTimeout(timeout); + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe("Authentication error"); + + success(done, io, clientSocket); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + }); + }); +}); diff --git a/test/js/third_party/socket.io/socket.io-socket-timeout.test.ts b/test/js/third_party/socket.io/socket.io-socket-timeout.test.ts new file mode 100644 index 000000000..9bcb4f53b --- /dev/null +++ b/test/js/third_party/socket.io/socket.io-socket-timeout.test.ts @@ -0,0 +1,137 @@ +// TODO: uncomment when Blob bug in isBinary is fixed + +import { Server } from "socket.io"; +import { describe, it, expect } from "bun:test"; + +import { success, fail, createClient } from "./support/util.ts"; + +describe("timeout", () => { + it.skip("should timeout if the client does not acknowledge the event", done => { + const io = new Server(0); + const client = createClient(io, "/"); + try { + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), client); + }, 200); + + io.on("connection", socket => { + socket.timeout(50).emit("unknown", err => { + clearTimeout(timeout); + try { + expect(err).toBeInstanceOf(Error); + success(done, io, client); + } catch (err) { + fail(done, io, err, client); + } + }); + }); + } catch (err) { + fail(done, io, err, client); + } + }); + + it.skip("should timeout if the client does not acknowledge the event in time", done => { + const io = new Server(0); + const client = createClient(io, "/"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), client); + }, 500); + + client.on("echo", (arg, cb) => { + cb(arg); + }); + + let count = 0; + + io.on("connection", socket => { + socket.timeout(0).emit("echo", 42, err => { + try { + expect(err).toBeInstanceOf(Error); + count++; + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, client); + } + }); + }); + + setTimeout(() => { + clearTimeout(timeout); + try { + expect(count).toBe(1); + success(done, io, client); + } catch (err) { + fail(done, io, err, client); + } + }, 200); + }); + + it.skip("should not timeout if the client does acknowledge the event", done => { + const io = new Server(0); + const client = createClient(io, "/"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), client); + }, 200); + + client.on("echo", (arg, cb) => { + cb(arg); + }); + + io.on("connection", socket => { + socket.timeout(50).emit("echo", 42, (err, value) => { + clearTimeout(timeout); + try { + expect(err).toBe(null); + expect(value).toBe(42); + success(done, io, client); + } catch (err) { + fail(done, io, err, client); + } + }); + }); + }); + + it.skip("should timeout if the client does not acknowledge the event (promise)", done => { + const io = new Server(0); + const client = createClient(io, "/"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), client); + }, 200); + + io.on("connection", async socket => { + try { + await socket.timeout(50).emitWithAck("unknown"); + clearTimeout(timeout); + fail(done, io, new Error("timeout"), client); + } catch (err) { + clearTimeout(timeout); + expect(err).toBeInstanceOf(Error); + success(done, io, client); + } + }); + }); + + it.skip("should not timeout if the client does acknowledge the event (promise)", done => { + const io = new Server(0); + const client = createClient(io, "/"); + const timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), client); + }, 200); + + client.on("echo", (arg, cb) => { + cb(arg); + }); + + io.on("connection", async socket => { + try { + const value = await socket.timeout(50).emitWithAck("echo", 42); + clearTimeout(timeout); + expect(value).toBe(42); + success(done, io, client); + } catch (err) { + clearTimeout(timeout); + fail(done, io, err, client); + } + }); + }); +}); diff --git a/test/js/third_party/socket.io/socket.io-utility-methods.test.ts b/test/js/third_party/socket.io/socket.io-utility-methods.test.ts new file mode 100644 index 000000000..2326e92c3 --- /dev/null +++ b/test/js/third_party/socket.io/socket.io-utility-methods.test.ts @@ -0,0 +1,181 @@ +import { createServer } from "http"; +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { io as ioc, Socket as ClientSocket } from "socket.io-client"; +import { Adapter, BroadcastOptions } from "socket.io-adapter"; +import type { AddressInfo } from "net"; +import { Server } from "socket.io"; + +import { createPartialDone } from "./support/util.ts"; + +const SOCKETS_COUNT = 3; + +class DummyAdapter extends Adapter { + fetchSockets(opts: BroadcastOptions): Promise<any[]> { + return Promise.resolve([ + { + id: "42", + handshake: { + headers: { + accept: "*/*", + }, + query: { + transport: "polling", + EIO: "4", + }, + }, + rooms: ["42", "room1"], + data: { + username: "john", + }, + }, + ]); + } +} + +describe("utility methods", () => { + let io: Server, clientSockets: ClientSocket[], serverSockets: Socket[]; + beforeEach(done => { + const srv = createServer(); + io = new Server(srv); + const timeout = setTimeout(() => { + serverSockets = []; + // done(new Error("timeout")); + done(); + }, 300); + + srv.listen(() => { + const port = (srv.address() as AddressInfo).port; + + clientSockets = []; + for (let i = 0; i < SOCKETS_COUNT; i++) { + clientSockets.push( + ioc(`http://localhost:${port}`, { + // FIXME needed so that clients are properly closed + transports: ["websocket"], + }), + ); + } + + serverSockets = []; + io.on("connection", (socket: Socket) => { + serverSockets.push(socket); + if (serverSockets.length === SOCKETS_COUNT) { + clearTimeout(timeout); + done(); + } + }); + }); + }); + + afterEach(() => { + io.close(); + clientSockets.forEach(socket => socket.disconnect()); + }); + + describe("fetchSockets", () => { + it.skip("returns all socket instances", async () => { + const sockets = await io.fetchSockets(); + expect(sockets.length).toBe(3); + }); + + it.skip("returns all socket instances in the given room", async () => { + serverSockets[0]?.join(["room1", "room2"]); + serverSockets[1]?.join("room1"); + serverSockets[2]?.join("room2"); + const sockets = await io.in("room1").fetchSockets(); + expect(sockets.length).toBe(2); + }); + + it.skip("works with a custom adapter", async () => { + io.adapter(DummyAdapter); + const sockets = await io.fetchSockets(); + expect(sockets.length).toBe(1); + const remoteSocket = sockets[0]; + expect(remoteSocket.id).toBe("42"); + expect(remoteSocket.rooms).toStrictEqual(new Set(["42", "room1"])); + expect(remoteSocket.data).toStrictEqual({ username: "john" }); + }); + }); + + describe("socketsJoin", () => { + it("makes all socket instances join the given room", () => { + io.socketsJoin("room1"); + serverSockets.forEach(socket => { + expect(socket.rooms).toContain("room1"); + }); + }); + + it.skip("makes all socket instances in a room join the given room", () => { + serverSockets[0]?.join(["room1", "room2"]); + serverSockets[1]?.join("room1"); + serverSockets[2]?.join("room2"); + io.in("room1").socketsJoin("room3"); + expect(serverSockets[0]?.rooms).toContain("room3"); + expect(serverSockets[1]?.rooms).toContain("room3"); + expect(serverSockets[2]?.rooms).not.toContain("room3"); + }); + }); + + describe("socketsLeave", () => { + it.skip("makes all socket instances leave the given room", () => { + serverSockets[0]?.join(["room1", "room2"]); + serverSockets[1]?.join("room1"); + serverSockets[2]?.join("room2"); + io.socketsLeave("room1"); + expect(serverSockets[0]?.rooms).toContain("room2"); + expect(serverSockets[0]?.rooms).toContain("room1"); + expect(serverSockets[1]?.rooms).not.toContain("room1"); + }); + + it.skip("makes all socket instances in a room leave the given room", () => { + serverSockets[0]?.join(["room1", "room2"]); + serverSockets[1]?.join("room1"); + serverSockets[2]?.join("room2"); + io.in("room2").socketsLeave("room1"); + expect(serverSockets[0]?.rooms).toContain("room2"); + expect(serverSockets[0]?.rooms).not.toContain("room1"); + expect(serverSockets[1]?.rooms).toContain("room1"); + }); + }); + + describe("disconnectSockets", () => { + it.skip("makes all socket instances disconnect", done => { + io.disconnectSockets(true); + const timeout = setTimeout(() => { + done(new Error("timeout")); + }, 300); + + const partialDone = createPartialDone(3, err => { + clearTimeout(timeout); + done(err); + }); + + clientSockets[0].on("disconnect", partialDone); + clientSockets[1].on("disconnect", partialDone); + clientSockets[2].on("disconnect", partialDone); + }); + + it.skip("makes all socket instances in a room disconnect", done => { + const timeout = setTimeout(() => { + done(new Error("timeout")); + }, 300); + + serverSockets[0]?.join(["room1", "room2"]); + serverSockets[1]?.join("room1"); + serverSockets[2]?.join("room2"); + io.in("room2").disconnectSockets(true); + + const partialDone = createPartialDone(2, err => { + clearTimeout(timeout); + clientSockets[1].off("disconnect"); + done(err); + }); + + clientSockets[0].on("disconnect", partialDone); + clientSockets[1].on("disconnect", () => { + done(new Error("should not happen")); + }); + clientSockets[2].on("disconnect", partialDone); + }); + }); +}); diff --git a/test/js/third_party/socket.io/socket.io-uws.test.ts b/test/js/third_party/socket.io/socket.io-uws.test.ts new file mode 100644 index 000000000..4749caa7a --- /dev/null +++ b/test/js/third_party/socket.io/socket.io-uws.test.ts @@ -0,0 +1,208 @@ +// import { App, us_socket_local_port, us_listen_socket_close } from "uWebSockets.js"; +// uWS throws an error when trying to import it +import { Server } from "socket.io"; +import { io as ioc, Socket as ClientSocket } from "socket.io-client"; +import request from "supertest"; +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; + +const createPartialDone = (done: (err?: Error) => void, count: number) => { + let i = 0; + return () => { + if (++i === count) { + done(); + } else if (i > count) { + done(new Error(`partialDone() called too many times: ${i} > ${count}`)); + } + }; +}; + +const shouldNotHappen = done => () => done(new Error("should not happen")); + +describe("socket.io with uWebSocket.js-based engine", () => { + let io: Server, + uwsSocket: any, + port: number, + client: ClientSocket, + clientWSOnly: ClientSocket, + clientPollingOnly: ClientSocket, + clientCustomNamespace: ClientSocket; + + beforeEach(done => { + const app = App(); + io = new Server(); + io.attachApp(app); + + io.of("/custom"); + + app.listen(0, listenSocket => { + uwsSocket = listenSocket; + port = us_socket_local_port(listenSocket); + + client = ioc(`http://localhost:${port}`); + clientWSOnly = ioc(`http://localhost:${port}`, { + transports: ["websocket"], + }); + clientPollingOnly = ioc(`http://localhost:${port}`, { + transports: ["polling"], + }); + clientCustomNamespace = ioc(`http://localhost:${port}/custom`); + }); + + const partialDone = createPartialDone(done, 4); + client.on("connect", partialDone); + clientWSOnly.once("connect", partialDone); + clientPollingOnly.on("connect", partialDone); + clientCustomNamespace.on("connect", partialDone); + }); + + afterEach(() => { + io.close(); + us_listen_socket_close(uwsSocket); + + client.disconnect(); + clientWSOnly.disconnect(); + clientPollingOnly.disconnect(); + clientCustomNamespace.disconnect(); + }); + + it.skip("should broadcast", done => { + const partialDone = createPartialDone(done, 3); + + client.on("hello", partialDone); + clientWSOnly.on("hello", partialDone); + clientPollingOnly.on("hello", partialDone); + clientCustomNamespace.on("hello", shouldNotHappen(done)); + + io.emit("hello"); + }); + + it.skip("should broadcast in a namespace", done => { + client.on("hello", shouldNotHappen(done)); + clientWSOnly.on("hello", shouldNotHappen(done)); + clientPollingOnly.on("hello", shouldNotHappen(done)); + clientCustomNamespace.on("hello", done); + + io.of("/custom").emit("hello"); + }); + + it.skip("should broadcast in a dynamic namespace", done => { + const dynamicNamespace = io.of(/\/dynamic-\d+/); + const dynamicClient = clientWSOnly.io.socket("/dynamic-101"); + + dynamicClient.on("connect", () => { + dynamicNamespace.emit("hello"); + }); + + dynamicClient.on("hello", () => { + dynamicClient.disconnect(); + done(); + }); + }); + + it.skip("should broadcast binary content", done => { + const partialDone = createPartialDone(done, 3); + + client.on("hello", partialDone); + clientWSOnly.on("hello", partialDone); + clientPollingOnly.on("hello", partialDone); + clientCustomNamespace.on("hello", shouldNotHappen(done)); + + io.emit("hello", Buffer.from([1, 2, 3])); + }); + + it.skip("should broadcast volatile packet with binary content", done => { + const partialDone = createPartialDone(done, 3); + + client.on("hello", partialDone); + clientWSOnly.on("hello", partialDone); + clientPollingOnly.on("hello", partialDone); + clientCustomNamespace.on("hello", shouldNotHappen(done)); + + // wait to make sure there are no packets being sent for opening the connection + setTimeout(() => { + io.volatile.emit("hello", Buffer.from([1, 2, 3])); + }, 20); + }); + + it.skip("should broadcast in a room", done => { + const partialDone = createPartialDone(done, 2); + + client.on("hello", shouldNotHappen(done)); + clientWSOnly.on("hello", partialDone); + clientPollingOnly.on("hello", partialDone); + clientCustomNamespace.on("hello", shouldNotHappen(done)); + + io.of("/").sockets.get(clientWSOnly.id)!.join("room1"); + io.of("/").sockets.get(clientPollingOnly.id)!.join("room1"); + + io.to("room1").emit("hello"); + }); + + it.skip("should broadcast in multiple rooms", done => { + const partialDone = createPartialDone(done, 2); + + client.on("hello", shouldNotHappen(done)); + clientWSOnly.on("hello", partialDone); + clientPollingOnly.on("hello", partialDone); + clientCustomNamespace.on("hello", shouldNotHappen(done)); + + io.of("/").sockets.get(clientWSOnly.id)!.join("room1"); + io.of("/").sockets.get(clientPollingOnly.id)!.join("room2"); + + io.to(["room1", "room2"]).emit("hello"); + }); + + it.skip("should broadcast in all but a given room", done => { + const partialDone = createPartialDone(done, 2); + + client.on("hello", partialDone); + clientWSOnly.on("hello", partialDone); + clientPollingOnly.on("hello", shouldNotHappen(done)); + clientCustomNamespace.on("hello", shouldNotHappen(done)); + + io.of("/").sockets.get(clientWSOnly.id)!.join("room1"); + io.of("/").sockets.get(clientPollingOnly.id)!.join("room2"); + + io.except("room2").emit("hello"); + }); + + it.skip("should work even after leaving room", done => { + const partialDone = createPartialDone(done, 2); + + client.on("hello", partialDone); + clientWSOnly.on("hello", shouldNotHappen(done)); + clientPollingOnly.on("hello", partialDone); + clientCustomNamespace.on("hello", shouldNotHappen(done)); + + io.of("/").sockets.get(client.id)!.join("room1"); + io.of("/").sockets.get(clientPollingOnly.id)!.join("room1"); + + io.of("/").sockets.get(clientWSOnly.id)!.join("room1"); + io.of("/").sockets.get(clientWSOnly.id)!.leave("room1"); + + io.to("room1").emit("hello"); + }); + + it.skip("should not crash when socket is disconnected before the upgrade", done => { + client.on("disconnect", () => done()); + + io.of("/").sockets.get(client.id)!.disconnect(); + }); + + it.skip("should serve static files", done => { + const clientVersion = require("socket.io-client/package.json").version; + + request(`http://localhost:${port}`) + .get("/socket.io/socket.io.js") + .buffer(true) + .end((err, res) => { + if (err) return done(err); + expect(res.headers["content-type"]).toBe("application/javascript; charset=utf-8"); + expect(res.headers.etag).toBe('"' + clientVersion + '"'); + expect(res.headers["x-sourcemap"]).toBe(undefined); + expect(res.text).toMatch(/engine\.io/); + expect(res.status).toBe(200); + done(); + }); + }); +}); diff --git a/test/js/third_party/socket.io/socket.io.test.ts b/test/js/third_party/socket.io/socket.io.test.ts new file mode 100644 index 000000000..9d527f6b7 --- /dev/null +++ b/test/js/third_party/socket.io/socket.io.test.ts @@ -0,0 +1,1554 @@ +import { describe, it, expect } from "bun:test"; + +import fs from "fs"; +import { join } from "path"; +import { createClient, createPartialDone, getPort, success, fail } from "./support/util.ts"; +import { Server } from "socket.io"; + +describe("socket", () => { + it("should not fire events more than once after manually reconnecting", done => { + const io = new Server(0); + const clientSocket = createClient(io, "/", { reconnection: false }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 200); + clientSocket.on("connect", function init() { + clientSocket.off("connect", init); + clientSocket.io.engine.close(); + + process.nextTick(() => { + clientSocket.connect(); + }); + clientSocket.on("connect", () => { + clearTimeout(timeout); + success(done, io, clientSocket); + }); + }); + }); + + it.skip("should not fire reconnect_failed event more than once when server closed", done => { + const io = new Server(0); + const clientSocket = createClient(io, "/", { + reconnectionAttempts: 3, + reconnectionDelay: 100, + }); + + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 200); + + clientSocket.on("connect", () => { + io.close(); + }); + + clientSocket.io.on("reconnect_failed", () => { + clearTimeout(timeout); + success(done, io, clientSocket); + }); + }); + + it("should receive events", done => { + const io = new Server(0); + const socket = createClient(io); + + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + s.on("random", (a, b, c) => { + clearTimeout(timeout); + try { + expect(a).toBe(1); + expect(b).toBe("2"); + expect(c).toStrictEqual([3]); + + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + socket.emit("random", 1, "2", [3]); + }); + }); + + it("should receive message events through `send`", done => { + const io = new Server(0); + const socket = createClient(io); + + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + s.on("message", a => { + clearInterval(timeout); + try { + expect(a).toBe(1337); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + socket.send(1337); + }); + }); + + it("should error with null messages", done => { + const io = new Server(0); + const socket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + io.on("connection", s => { + s.on("message", a => { + clearTimeout(timeout); + try { + expect(a).toBe(null); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + socket.send(null); + }); + }); + + it("should handle transport null messages", done => { + const io = new Server(0); + const socket = createClient(io, "/", { reconnection: false }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + io.on("connection", s => { + s.on("error", err => { + try { + expect(err).toBeInstanceOf(Error); + } catch (err) { + fail(done, io, err, socket); + return; + } + s.on("disconnect", reason => { + clearTimeout(timeout); + try { + expect(reason).toBe("forced close"); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + (s as any).client.ondata(null); + }); + }); + + it("should emit events", done => { + const io = new Server(0); + const socket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + socket.on("woot", a => { + clearTimeout(timeout); + + try { + expect(a).toBe("tobi"); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + io.on("connection", s => { + s.emit("woot", "tobi"); + }); + }); + + it("should emit events with utf8 multibyte character", done => { + const io = new Server(0); + const socket = createClient(io); + let i = 0; + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + socket.on("hoot", a => { + try { + expect(a).toBe("utf8 — string"); + i++; + + if (3 == i) { + clearTimeout(timeout); + success(done, io, socket); + } + } catch (err) { + fail(done, io, err, socket); + } + }); + io.on("connection", s => { + s.emit("hoot", "utf8 — string"); + s.emit("hoot", "utf8 — string"); + s.emit("hoot", "utf8 — string"); + }); + }); + + it("should emit events with binary data", done => { + const io = new Server(0); + const socket = createClient(io); + + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + let imageData: any; + socket.on("bun", a => { + clearTimeout(timeout); + try { + expect(Buffer.isBuffer(a)).toBe(true); + expect(imageData.length).toStrictEqual(a.length); + expect(imageData[0]).toStrictEqual(a[0]); + expect(imageData[imageData.length - 1]).toStrictEqual(a[a.length - 1]); + + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + io.on("connection", s => { + fs.readFile(join(__dirname, "support", "bun.png"), (err, data) => { + if (err) return done(err); + imageData = data; + s.emit("bun", data); + }); + }); + }); + + it.skip("should emit events with several types of data (including binary)", done => { + const io = new Server(0); + const socket = createClient(io); + + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + socket.on("multiple", (a, b, c, d, e, f) => { + clearTimeout(timeout); + try { + expect(a).toBe(1); + expect(Buffer.isBuffer(b)).toBe(true); + expect(c).toBe("3"); + expect(d).toStrictEqual([4]); + expect(Buffer.isBuffer(e)).toBe(true); + expect(Buffer.isBuffer(f[0])).toBe(true); + expect(f[1]).toBe("swag"); + expect(Buffer.isBuffer(f[2])).toBe(true); + + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + io.on("connection", s => { + fs.readFile(join(__dirname, "support", "bun.png"), (err, data) => { + if (err) return done(err); + const buf = Buffer.from("asdfasdf", "utf8"); + s.emit("multiple", 1, data, "3", [4], buf, [data, "swag", buf]); + }); + }); + }); + + it("should receive events with binary data", done => { + const io = new Server(0); + const socket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + s.on("buff", a => { + clearTimeout(timeout); + try { + expect(Buffer.isBuffer(a)).toBe(true); + + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + const buf = Buffer.from("abcdefg", "utf8"); + socket.emit("buff", buf); + }); + }); + + it("should receive events with several types of data (including binary)", done => { + const io = new Server(0); + const socket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + s.on("multiple", (a, b, c, d, e, f) => { + clearTimeout(timeout); + try { + expect(a).toBe(1); + expect(Buffer.isBuffer(b)).toBe(true); + expect(c).toBe("3"); + expect(d).toStrictEqual([4]); + expect(Buffer.isBuffer(e)).toBe(true); + expect(Buffer.isBuffer(f[0])).toBe(true); + expect(f[1]).toBe("swag"); + expect(Buffer.isBuffer(f[2])).toBe(true); + + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + fs.readFile(join(__dirname, "support", "bun.png"), (err, data) => { + if (err) return done(err); + const buf = Buffer.from("asdfasdf", "utf8"); + socket.emit("multiple", 1, data, "3", [4], buf, [data, "swag", buf]); + }); + }); + }); + + it("should not emit volatile event after regular event (polling)", done => { + const io = new Server(0, { transports: ["polling"] }); + + let counter = 0; + io.on("connection", s => { + s.emit("ev", "data"); + s.volatile.emit("ev", "data"); + }); + + const socket = createClient(io, "/", { transports: ["polling"] }); + socket.on("ev", () => { + counter++; + }); + + setTimeout(() => { + try { + expect(counter).toBe(1); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }, 200); + }); + + it.skip("should not emit volatile event after regular event (ws)", done => { + const io = new Server(0, { transports: ["websocket"] }); + + let counter = 0; + io.on("connection", s => { + s.emit("ev", "data"); + s.volatile.emit("ev", "data"); + }); + + const socket = createClient(io, "/", { transports: ["websocket"] }); + socket.on("ev", () => { + counter++; + }); + + setTimeout(() => { + try { + expect(counter).toBe(1); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }, 200); + }); + + it("should emit volatile event (polling)", done => { + const io = new Server(0, { transports: ["polling"] }); + + let counter = 0; + io.on("connection", s => { + // Wait to make sure there are no packets being sent for opening the connection + setTimeout(() => { + s.volatile.emit("ev", "data"); + }, 100); + }); + + const socket = createClient(io, "/", { transports: ["polling"] }); + socket.on("ev", () => { + counter++; + }); + + setTimeout(() => { + try { + expect(counter).toBe(1); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }, 500); + }); + + it.skip("should emit volatile event (ws)", done => { + const io = new Server(0, { transports: ["websocket"] }); + + let counter = 0; + io.on("connection", s => { + // Wait to make sure there are no packets being sent for opening the connection + setTimeout(() => { + s.volatile.emit("ev", "data"); + }, 20); + }); + + const socket = createClient(io, "/", { transports: ["websocket"] }); + socket.on("ev", () => { + counter++; + }); + + setTimeout(() => { + try { + expect(counter).toBe(1); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }, 200); + }); + + it("should emit only one consecutive volatile event (polling)", done => { + const io = new Server(0, { transports: ["polling"] }); + + let counter = 0; + io.on("connection", s => { + // Wait to make sure there are no packets being sent for opening the connection + setTimeout(() => { + s.volatile.emit("ev", "data"); + s.volatile.emit("ev", "data"); + }, 100); + }); + + const socket = createClient(io, "/", { transports: ["polling"] }); + socket.on("ev", () => { + counter++; + }); + + setTimeout(() => { + try { + expect(counter).toBe(1); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }, 500); + }); + + it.skip("should emit only one consecutive volatile event (ws)", done => { + const io = new Server(0, { transports: ["websocket"] }); + + let counter = 0; + io.on("connection", s => { + // Wait to make sure there are no packets being sent for opening the connection + setTimeout(() => { + s.volatile.emit("ev", "data"); + s.volatile.emit("ev", "data"); + }, 20); + }); + + const socket = createClient(io, "/", { transports: ["websocket"] }); + socket.on("ev", () => { + counter++; + }); + + setTimeout(() => { + try { + expect(counter).toBe(1); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }, 200); + }); + + it.skip("should emit only one consecutive volatile event with binary (ws)", done => { + const io = new Server(0, { transports: ["websocket"] }); + + let counter = 0; + io.on("connection", s => { + // Wait to make sure there are no packets being sent for opening the connection + setTimeout(() => { + s.volatile.emit("ev", Buffer.from([1, 2, 3])); + s.volatile.emit("ev", Buffer.from([4, 5, 6])); + }, 20); + }); + + const socket = createClient(io, "/", { transports: ["websocket"] }); + socket.on("ev", () => { + counter++; + }); + + setTimeout(() => { + try { + expect(counter).toBe(1); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }, 200); + }); + + it.skip("should broadcast only one consecutive volatile event with binary (ws)", done => { + const io = new Server(0, { transports: ["websocket"] }); + + let counter = 0; + io.on("connection", s => { + // Wait to make sure there are no packets being sent for opening the connection + setTimeout(() => { + io.volatile.emit("ev", Buffer.from([1, 2, 3])); + io.volatile.emit("ev", Buffer.from([4, 5, 6])); + }, 20); + }); + + const socket = createClient(io, "/", { transports: ["websocket"] }); + socket.on("ev", () => { + counter++; + }); + + setTimeout(() => { + try { + expect(counter).toBe(1); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }, 2000); + }); + + it("should emit regular events after trying a failed volatile event (polling)", done => { + const io = new Server(0, { transports: ["polling"] }); + + let counter = 0; + io.on("connection", s => { + // Wait to make sure there are no packets being sent for opening the connection + setTimeout(() => { + s.emit("ev", "data"); + s.volatile.emit("ev", "data"); + s.emit("ev", "data"); + }, 20); + }); + + const socket = createClient(io, "/", { transports: ["polling"] }); + socket.on("ev", () => { + counter++; + }); + + setTimeout(() => { + try { + expect(counter).toBe(2); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }, 200); + }); + + it.skip("should emit regular events after trying a failed volatile event (ws)", done => { + const io = new Server(0, { transports: ["websocket"] }); + + let counter = 0; + io.on("connection", s => { + // Wait to make sure there are no packets being sent for opening the connection + setTimeout(() => { + s.emit("ev", "data"); + s.volatile.emit("ev", "data"); + s.emit("ev", "data"); + }, 20); + }); + + const socket = createClient(io, "/", { transports: ["websocket"] }); + socket.on("ev", () => { + counter++; + }); + + setTimeout(() => { + try { + expect(counter).toBe(2); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }, 200); + }); + + it("should emit message events through `send`", done => { + const io = new Server(0); + const socket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + socket.on("message", a => { + clearTimeout(timeout); + try { + expect(a).toBe("a"); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + io.on("connection", s => { + s.send("a"); + }); + }); + + it("should receive event with callbacks", done => { + const io = new Server(0); + const socket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + io.on("connection", s => { + s.on("woot", fn => { + fn(1, 2); + }); + socket.emit("woot", (a, b) => { + clearTimeout(timeout); + try { + expect(a).toBe(1); + expect(b).toBe(2); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + }); + + it("should receive all events emitted from namespaced client immediately and in order", done => { + const io = new Server(0); + let total = 0; + let timeout: any; + io.of("/chat", s => { + s.on("hi", letter => { + total++; + if (total == 2) { + clearInterval(timeout); + expect(letter).toBe("b"); + success(done, io, chat); + } else if (total == 1 && letter != "a") { + fail(done, io, new Error("events out of order"), chat); + } + }); + }); + + const chat = createClient(io, "/chat"); + timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), chat); + }, 2000); + + chat.emit("hi", "a"); + setTimeout(() => { + chat.emit("hi", "b"); + }, 50); + }); + + it("should emit events with callbacks", done => { + const io = new Server(0); + const socket = createClient(io); + + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + socket.on("hi", fn => { + fn(); + }); + s.emit("hi", () => { + clearTimeout(timeout); + success(done, io, socket); + }); + }); + }); + + it("should receive events with args and callback", done => { + const io = new Server(0); + const socket = createClient(io); + + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + s.on("woot", (a, b, fn) => { + try { + expect(a).toBe(1); + expect(b).toBe(2); + fn(); + } catch (err) { + fail(done, io, err, socket); + } + }); + socket.emit("woot", 1, 2, () => { + clearTimeout(timeout); + success(done, io, socket); + }); + }); + }); + + it("should emit events with args and callback", done => { + const io = new Server(0); + const socket = createClient(io); + + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + socket.on("hi", (a, b, fn) => { + try { + expect(a).toBe(1); + expect(b).toBe(2); + fn(); + } catch (err) { + fail(done, io, err, socket); + } + }); + s.emit("hi", 1, 2, () => { + clearTimeout(timeout); + success(done, io, socket); + }); + }); + }); + + it("should receive events with binary args and callbacks", done => { + const io = new Server(0); + const socket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + s.on("woot", (buf, fn) => { + try { + expect(Buffer.isBuffer(buf)).toBe(true); + fn(1, 2); + } catch (err) { + fail(done, io, err, socket); + } + }); + socket.emit("woot", Buffer.alloc(3), (a, b) => { + clearTimeout(timeout); + try { + expect(a).toBe(1); + expect(b).toBe(2); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + }); + + it("should emit events with binary args and callback", done => { + const io = new Server(0); + const socket = createClient(io); + + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + socket.on("hi", (a, fn) => { + try { + expect(Buffer.isBuffer(a)).toBe(true); + fn(); + } catch (err) { + fail(done, io, err, socket); + } + }); + s.emit("hi", Buffer.alloc(4), () => { + clearTimeout(timeout); + success(done, io, socket); + }); + }); + }); + + it("should emit events and receive binary data in a callback", done => { + const io = new Server(0); + const socket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + socket.on("hi", fn => { + fn(Buffer.alloc(1)); + }); + s.emit("hi", a => { + clearTimeout(timeout); + try { + expect(Buffer.isBuffer(a)).toBe(true); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + }); + + it("should receive events and pass binary data in a callback", done => { + const io = new Server(0); + const socket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + s.on("woot", fn => { + fn(Buffer.alloc(2)); + }); + socket.emit("woot", a => { + clearTimeout(timeout); + try { + expect(Buffer.isBuffer(a)).toBe(true); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + }); + + it("should emit an event and wait for the acknowledgement", done => { + const io = new Server(0); + const socket = createClient(io); + + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", async s => { + socket.on("hi", (a, b, fn) => { + try { + expect(a).toBe(1); + expect(b).toBe(2); + fn(3); + } catch (err) { + fail(done, io, err, socket); + } + }); + + const val = await s.emitWithAck("hi", 1, 2); + clearTimeout(timeout); + try { + expect(val).toBe(3); + + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("should have access to the client", done => { + const io = new Server(0); + const socket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + clearTimeout(timeout); + try { + expect(typeof s.client).toBe("object"); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("should have access to the connection", done => { + const io = new Server(0); + const socket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + clearTimeout(timeout); + try { + expect(typeof s.client.conn).toBe("object"); + expect(typeof s.conn).toBe("object"); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("should have access to the request", done => { + const io = new Server(0); + const socket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + clearTimeout(timeout); + try { + expect(typeof s.client.request.headers).toBe("object"); + expect(typeof s.request.headers).toBe("object"); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("should see query parameters in the request", done => { + const io = new Server(0); + const socket = createClient(io, "/", { query: { key1: 1, key2: 2 } }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 200); + + io.on("connection", s => { + clearTimeout(timeout); + try { + const parsed = require("url").parse(s.request.url); + const query = require("querystring").parse(parsed.query); + expect(query.key1).toBe("1"); + expect(query.key2).toBe("2"); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + it("should see query parameters sent from secondary namespace connections in handshake object", done => { + const io = new Server(0); + const client1 = createClient(io); + const client2 = createClient(io, "/connection2", { + auth: { key1: "aa", key2: "&=bb" }, + }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), client1, client2); + }, 200); + + io.on("connection", s => {}); + io.of("/connection2").on("connection", s => { + clearTimeout(timeout); + + try { + expect(s.handshake.query.key1).toBe(undefined); + expect(s.handshake.query.EIO).toBe("4"); + expect(s.handshake.auth.key1).toBe("aa"); + expect(s.handshake.auth.key2).toBe("&=bb"); + success(done, io, client1, client2); + } catch (err) { + fail(done, io, err, client1, client2); + } + }); + }); + + it.skip("should handle very large json", function (done) { + const io = new Server(0, { perMessageDeflate: false }); + let received = 0; + + const socket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 30000); + + socket.on("big", a => { + try { + expect(Buffer.isBuffer(a.json)).toBe(false); + if (++received == 3) { + clearTimeout(timeout); + success(done, io, socket); + } else socket.emit("big", a); + } catch (err) { + fail(done, io, err, socket); + } + }); + io.on("connection", s => { + fs.readFile(join(__dirname, "fixtures", "big.json"), (err, data: any) => { + if (err) return done(err); + data = JSON.parse(data); + s.emit("big", { hello: "friend", json: data }); + }); + s.on("big", a => { + s.emit("big", a); + }); + }); + }); + + it.skip("should handle very large binary data", function (done) { + const io = new Server(0, { perMessageDeflate: false }); + let received = 0; + + const socket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 30000); + + socket.on("big", a => { + try { + expect(Buffer.isBuffer(a.image)).toBe(true); + if (++received == 3) { + clearTimeout(timeout); + success(done, io, socket); + } else socket.emit("big", a); + } catch (err) { + fail(done, io, err, socket); + } + }); + io.on("connection", s => { + fs.readFile(join(__dirname, "fixtures", "big.jpg"), (err, data) => { + if (err) return done(err); + s.emit("big", { hello: "friend", image: data }); + }); + s.on("big", a => { + expect(Buffer.isBuffer(a.image)).toBe(true); + s.emit("big", a); + }); + }); + }); + + it("should be able to emit after server close and restart", done => { + const io = new Server(0); + let timeout: any; + io.on("connection", socket => { + socket.on("ev", data => { + clearTimeout(timeout); + try { + expect(data).toBe("payload"); + success(done, io, clientSocket); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + }); + + const port = getPort(io); + const clientSocket = createClient(io, "/", { + reconnectionAttempts: 10, + reconnectionDelay: 100, + }); + timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 300); + clientSocket.once("connect", () => { + io.close(() => { + clientSocket.io.on("reconnect", () => { + clientSocket.emit("ev", "payload"); + }); + io.listen(port); + }); + }); + }); + + it("should enable compression by default", done => { + const io = new Server(0); + const socket = createClient(io, "/chat"); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + io.of("/chat").on("connection", s => { + s.conn.once("packetCreate", packet => { + clearTimeout(timeout); + try { + expect(packet.options.compress).toBe(true); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + s.emit("woot", "hi"); + }); + }); + + it("should disable compression", done => { + const io = new Server(0); + const socket = createClient(io, "/chat"); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + io.of("/chat").on("connection", s => { + s.conn.once("packetCreate", packet => { + clearTimeout(timeout); + try { + expect(packet.options.compress).toBe(false); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + s.compress(false).emit("woot", "hi"); + }); + }); + + it.skip("should error with raw binary and warn", done => { + const io = new Server(0); + const socket = createClient(io, "/", { reconnection: false }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + io.on("connection", s => { + s.conn.on("upgrade", () => { + console.log("\u001b[96mNote: warning expected and normal in test.\u001b[39m"); + // @ts-ignore + socket.io.engine.write("5woooot"); + setTimeout(() => { + clearTimeout(timeout); + success(done, io, socket); + }, 100); + }); + }); + }); + + // TODO: investigate IOT + it.skip("should not crash when receiving an error packet without handler", done => { + const io = new Server(0); + const socket = createClient(io, "/", { reconnection: false }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + io.on("connection", s => { + s.conn.on("upgrade", () => { + console.log("\u001b[96mNote: warning expected and normal in test.\u001b[39m"); + // @ts-ignore + socket.io.engine.write('44["handle me please"]'); + setTimeout(() => { + clearTimeout(timeout); + success(done, io, socket); + }, 100); + }); + }); + }); + + it.skip("should not crash with raw binary", done => { + const io = new Server(0); + const socket = createClient(io, "/", { reconnection: false }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + io.on("connection", s => { + s.once("error", err => { + clearTimeout(timeout); + try { + expect(err.message).toMatch(/Illegal attachments/); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + s.conn.on("upgrade", () => { + // @ts-ignore + socket.io.engine.write("5woooot"); + }); + }); + }); + + it.skip("should handle empty binary packet", done => { + const io = new Server(0); + const socket = createClient(io, "/", { reconnection: false }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + io.on("connection", s => { + s.once("error", err => { + clearTimeout(timeout); + try { + expect(err.message).toMatch(/Illegal attachments/); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + s.conn.on("upgrade", () => { + // @ts-ignore + socket.io.engine.write("5"); + }); + }); + }); + + it("should not crash when messing with Object prototype (and other globals)", done => { + // @ts-ignore + Object.prototype.foo = "bar"; + // @ts-ignore + global.File = ""; + // @ts-ignore + global.Blob = []; + const io = new Server(0); + const socket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + io.on("connection", () => { + clearTimeout(timeout); + success(done, io, socket); + }); + }); + + it("should throw on reserved event", done => { + const io = new Server(0); + + const socket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket); + }, 300); + + io.on("connection", s => { + clearTimeout(timeout); + try { + expect(() => s.emit("connect_error")).toThrow(/"connect_error" is a reserved event name/); + socket.close(); + success(done, io, socket); + } catch (err) { + fail(done, io, err, socket); + } + }); + }); + + // TODO: investigate weird error here + it.skip("should ignore a packet received after disconnection", done => { + const io = new Server(0); + const clientSocket = createClient(io); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 300); + + io.on("connection", socket => { + socket.on("test", () => { + fail(done, io, new Error("should not happen"), clientSocket); + }); + socket.on("disconnect", () => { + clearTimeout(timeout); + success(done, io, clientSocket); + }); + }); + + clientSocket.on("connect", () => { + clientSocket.emit("test", Buffer.alloc(10)); + clientSocket.disconnect(); + }); + }); + + it("should leave all rooms joined after a middleware failure", done => { + const io = new Server(0); + const client = createClient(io, "/"); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), client); + }, 300); + + io.use((socket, next) => { + socket.join("room1"); + next(new Error("nope")); + }); + + client.on("connect_error", () => { + clearTimeout(timeout); + try { + expect(io.of("/").adapter.rooms.size).toStrictEqual(0); + + io.close(); + success(done, io, client); + } catch (err) { + fail(done, io, err, client); + } + }); + }); + + it("should not join rooms after disconnection", done => { + const io = new Server(0); + const client = createClient(io, "/"); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), client); + }, 300); + + io.on("connection", socket => { + socket.disconnect(); + socket.join("room1"); + }); + + client.on("disconnect", () => { + clearTimeout(timeout); + try { + expect(io.of("/").adapter.rooms.size).toStrictEqual(0); + + io.close(); + success(done, io, client); + } catch (err) { + fail(done, io, err, client); + } + }); + }); + + describe("onAny", () => { + it("should call listener", done => { + const io = new Server(0); + const clientSocket = createClient(io, "/", { multiplex: false }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 300); + + clientSocket.emit("my-event", "123"); + + io.on("connection", socket => { + socket.onAny((event, arg1) => { + clearTimeout(timeout); + try { + expect(event).toBe("my-event"); + expect(arg1).toBe("123"); + success(done, io, clientSocket); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + }); + }); + + it("should prepend listener", done => { + const io = new Server(0); + const clientSocket = createClient(io, "/", { multiplex: false }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 300); + + clientSocket.emit("my-event", "123"); + + io.on("connection", socket => { + let count = 0; + + socket.onAny((event, arg1) => { + clearTimeout(timeout); + try { + expect(count).toBe(2); + success(done, io, clientSocket); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + + socket.prependAny(() => { + try { + expect(count++).toBe(1); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + + socket.prependAny(() => { + try { + expect(count++).toBe(0); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + }); + }); + + it("should remove listener", done => { + const io = new Server(0); + const clientSocket = createClient(io, "/", { multiplex: false }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 300); + + clientSocket.emit("my-event", "123"); + + io.on("connection", socket => { + const _fail = () => fail(done, io, new Error("should not happen"), clientSocket); + + socket.onAny(_fail); + socket.offAny(_fail); + try { + expect(socket.listenersAny.length).toBe(0); + } catch (err) { + fail(done, io, err, clientSocket); + } + socket.onAny(() => { + clearTimeout(timeout); + + success(done, io, clientSocket); + }); + }); + }); + }); + + describe("onAnyOutgoing", () => { + it("should call listener", done => { + const io = new Server(0); + const clientSocket = createClient(io, "/", { multiplex: false }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 300); + + io.on("connection", socket => { + socket.onAnyOutgoing((event, arg1) => { + clearTimeout(timeout); + + try { + expect(event).toBe("my-event"); + expect(arg1).toBe("123"); + + success(done, io, clientSocket); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + + socket.emit("my-event", "123"); + }); + }); + + it("should call listener when broadcasting", done => { + const io = new Server(0); + const clientSocket = createClient(io, "/", { multiplex: false }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 300); + io.on("connection", socket => { + socket.onAnyOutgoing((event, arg1) => { + clearTimeout(timeout); + try { + expect(event).toBe("my-event"); + expect(arg1).toBe("123"); + + success(done, io, clientSocket); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + + io.emit("my-event", "123"); + }); + }); + + it("should call listener when broadcasting binary data", done => { + const io = new Server(0); + const clientSocket = createClient(io, "/", { multiplex: false }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 300); + io.on("connection", socket => { + socket.onAnyOutgoing((event, arg1) => { + clearTimeout(timeout); + try { + expect(event).toBe("my-event"); + expect(arg1).toBeInstanceOf(Uint8Array); + + success(done, io, clientSocket); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + + io.emit("my-event", Uint8Array.of(1, 2, 3)); + }); + }); + + it("should prepend listener", done => { + const io = new Server(0); + const clientSocket = createClient(io, "/", { multiplex: false }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 300); + + io.on("connection", socket => { + let count = 0; + + socket.onAnyOutgoing((event, arg1) => { + clearTimeout(timeout); + try { + expect(count).toBe(2); + + success(done, io, clientSocket); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + + socket.prependAnyOutgoing(() => { + try { + expect(count++).toBe(1); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + + socket.prependAnyOutgoing(() => { + try { + expect(count++).toBe(0); + } catch (err) { + fail(done, io, err, clientSocket); + } + }); + + socket.emit("my-event", "123"); + }); + }); + + it("should remove listener", done => { + const io = new Server(0); + + const clientSocket = createClient(io, "/", { multiplex: false }); + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), clientSocket); + }, 300); + + io.on("connection", socket => { + const _fail = () => fail(done, io, new Error("fail"), clientSocket); + + socket.onAnyOutgoing(_fail); + socket.offAnyOutgoing(_fail); + try { + expect(socket.listenersAnyOutgoing.length).toBe(0); + } catch (err) { + fail(done, io, err, clientSocket); + } + + socket.onAnyOutgoing(() => { + clearTimeout(timeout); + success(done, io, clientSocket); + }); + + socket.emit("my-event", "123"); + }); + }); + + it.skip("should disconnect all namespaces when calling disconnect(true)", done => { + const io = new Server(0); + io.of("/foo"); + io.of("/bar"); + + const socket1 = createClient(io, "/", { + transports: ["websocket"], + }); + const socket2 = createClient(io, "/foo"); + const socket3 = createClient(io, "/bar"); + + let timeout = setTimeout(() => { + fail(done, io, new Error("timeout"), socket1, socket2, socket3); + }, 300); + + io.of("/bar").on("connection", socket => { + socket.disconnect(true); + }); + + const partialDone = createPartialDone(3, () => { + clearTimeout(timeout); + success(done, io, socket1, socket2, socket3); + }); + + socket1.on("disconnect", partialDone); + socket2.on("disconnect", partialDone); + socket3.on("disconnect", partialDone); + }); + }); +}); diff --git a/test/js/third_party/socket.io/support/bun.png b/test/js/third_party/socket.io/support/bun.png Binary files differnew file mode 100644 index 000000000..a6469e481 --- /dev/null +++ b/test/js/third_party/socket.io/support/bun.png diff --git a/test/js/third_party/socket.io/support/util.ts b/test/js/third_party/socket.io/support/util.ts new file mode 100644 index 000000000..597b40d65 --- /dev/null +++ b/test/js/third_party/socket.io/support/util.ts @@ -0,0 +1,86 @@ +import type { Server } from "socket.io"; +import request from "supertest"; + +import { io as ioc, ManagerOptions, Socket as ClientSocket, SocketOptions } from "socket.io-client"; + +export function createClient( + io: Server, + nsp: string = "/", + opts?: Partial<ManagerOptions & SocketOptions>, +): ClientSocket { + // @ts-ignore + const port = io.httpServer.address().port; + return ioc(`http://localhost:${port}${nsp}`, opts); +} + +export function success(done: Function, io: Server, ...clients: ClientSocket[]) { + io.close(); + clients.forEach(client => client.disconnect()); + done(); +} + +export function fail(done: Function, io: Server, err: any | unknown, ...clients: ClientSocket[]) { + io.close(); + clients.forEach(client => client.disconnect()); + done(err); +} + +export function getPort(io: Server): number { + // @ts-ignore + return io.httpServer.address().port; +} + +export function createPartialDone(count: number, done: (err?: Error) => void) { + let i = 0; + return () => { + if (++i === count) { + done(); + } else if (i > count) { + done(new Error(`partialDone() called too many times: ${i} > ${count}`)); + } + }; +} + +// TODO: update superagent as latest release now supports promises +export function eioHandshake(httpServer): Promise<string> { + return new Promise(resolve => { + request(httpServer) + .get("/socket.io/") + .query({ transport: "polling", EIO: 4 }) + .end((err, res) => { + const sid = JSON.parse(res.text.substring(1)).sid; + resolve(sid); + }); + }); +} + +export function eioPush(httpServer, sid: string, body: string): Promise<void> { + return new Promise(resolve => { + request(httpServer) + .post("/socket.io/") + .send(body) + .query({ transport: "polling", EIO: 4, sid }) + .expect(200) + .end(() => { + resolve(); + }); + }); +} + +export function eioPoll(httpServer, sid): Promise<string> { + return new Promise(resolve => { + request(httpServer) + .get("/socket.io/") + .query({ transport: "polling", EIO: 4, sid }) + .expect(200) + .end((err, res) => { + resolve(res.text); + }); + }); +} + +export function waitFor<T = unknown>(emitter, event) { + return new Promise<T>(resolve => { + emitter.once(event, resolve); + }); +} diff --git a/test/package.json b/test/package.json index c2caf7653..49ae422c3 100644 --- a/test/package.json +++ b/test/package.json @@ -16,7 +16,10 @@ "lodash": "^4.17.21", "svelte": "^3.55.1", "typescript": "^5.0.2", - "undici": "^5.20.0" + "undici": "^5.20.0", + "socket.io": "^4.6.1", + "socket.io-client": "^4.6.1", + "supertest": "^6.1.6" }, "private": true, "scripts": { |
