const isNodejs = typeof window === "undefined"; if (isNodejs) { // make the tests runnable in both the browser and Node.js await import("./node-imports.js"); } const { expect } = chai; const URL = "http://localhost:3000"; const WS_URL = URL.replace("http", "ws"); const PING_INTERVAL = 300; const PING_TIMEOUT = 200; function sleep(delay) { return new Promise((resolve) => setTimeout(resolve, delay)); } function createWebSocket(url) { const socket = new WebSocket(url); socket._eventBuffer = {}; socket._pendingPromises = {}; for (const eventType of ["open", "close", "message"]) { socket._eventBuffer[eventType] = []; socket._pendingPromises[eventType] = []; socket.addEventListener(eventType, (event) => { if (socket._pendingPromises[eventType].length) { socket._pendingPromises[eventType].shift()(event); } else { socket._eventBuffer[eventType].push(event); } }); } return socket; } function waitFor(socket, eventType) { if (socket._eventBuffer[eventType].length) { return Promise.resolve(socket._eventBuffer[eventType].shift()); } else { return new Promise((resolve) => { socket._pendingPromises[eventType].push(resolve); }); } } function waitForPackets(socket, count) { const packets = []; return new Promise((resolve) => { const handler = (event) => { if (event.data === "2") { // ignore PING packets return; } packets.push(event.data); if (packets.length === count) { socket.removeEventListener("message", handler); resolve(packets); } }; socket.addEventListener("message", handler); }); } async function initLongPollingSession() { const response = await fetch(`${URL}/socket.io/?EIO=4&transport=polling`); const content = await response.text(); return JSON.parse(content.substring(1)).sid; } async function initSocketIOConnection() { const socket = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=websocket` ); socket.binaryType = "arraybuffer"; await waitFor(socket, "message"); // Engine.IO handshake socket.send("40"); await waitFor(socket, "message"); // Socket.IO handshake await waitFor(socket, "message"); // "auth" packet return socket; } describe("Engine.IO protocol", () => { describe("handshake", () => { describe("HTTP long-polling", () => { it("should successfully open a session", async () => { const response = await fetch( `${URL}/socket.io/?EIO=4&transport=polling` ); expect(response.status).to.eql(200); const content = await response.text(); expect(content).to.startsWith("0"); const value = JSON.parse(content.substring(1)); expect(value).to.have.all.keys( "sid", "upgrades", "pingInterval", "pingTimeout", "maxPayload" ); expect(value.sid).to.be.a("string"); expect(value.upgrades).to.eql(["websocket"]); expect(value.pingInterval).to.eql(PING_INTERVAL); expect(value.pingTimeout).to.eql(PING_TIMEOUT); expect(value.maxPayload).to.eql(1000000); }); it("should fail with an invalid 'EIO' query parameter", async () => { const response = await fetch(`${URL}/socket.io/?transport=polling`); expect(response.status).to.eql(400); const response2 = await fetch( `${URL}/socket.io/?EIO=abc&transport=polling` ); expect(response2.status).to.eql(400); }); it("should fail with an invalid 'transport' query parameter", async () => { const response = await fetch(`${URL}/socket.io/?EIO=4`); expect(response.status).to.eql(400); const response2 = await fetch(`${URL}/socket.io/?EIO=4&transport=abc`); expect(response2.status).to.eql(400); }); it("should fail with an invalid request method", async () => { const response = await fetch( `${URL}/socket.io/?EIO=4&transport=polling`, { method: "post", } ); expect(response.status).to.eql(400); const response2 = await fetch( `${URL}/socket.io/?EIO=4&transport=polling`, { method: "put", } ); expect(response2.status).to.eql(400); }); }); describe("WebSocket", () => { it("should successfully open a session", async () => { const socket = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=websocket` ); const { data } = await waitFor(socket, "message"); expect(data).to.startsWith("0"); const value = JSON.parse(data.substring(1)); expect(value).to.have.all.keys( "sid", "upgrades", "pingInterval", "pingTimeout", "maxPayload" ); expect(value.sid).to.be.a("string"); expect(value.upgrades).to.eql([]); expect(value.pingInterval).to.eql(PING_INTERVAL); expect(value.pingTimeout).to.eql(PING_TIMEOUT); expect(value.maxPayload).to.eql(1000000); socket.close(); }); it("should fail with an invalid 'EIO' query parameter", async () => { const socket = createWebSocket( `${WS_URL}/socket.io/?transport=websocket` ); if (isNodejs) { socket.on("error", () => {}); } await waitFor(socket, "close"); const socket2 = createWebSocket( `${WS_URL}/socket.io/?EIO=abc&transport=websocket` ); if (isNodejs) { socket2.on("error", () => {}); } await waitFor(socket2, "close"); }); it("should fail with an invalid 'transport' query parameter", async () => { const socket = createWebSocket(`${WS_URL}/socket.io/?EIO=4`); if (isNodejs) { socket.on("error", () => {}); } await waitFor(socket, "close"); const socket2 = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=abc` ); if (isNodejs) { socket2.on("error", () => {}); } await waitFor(socket2, "close"); }); }); }); describe("heartbeat", function () { this.timeout(5000); describe("HTTP long-polling", () => { it("should send ping/pong packets", async () => { const sid = await initLongPollingSession(); for (let i = 0; i < 3; i++) { const pollResponse = await fetch( `${URL}/socket.io/?EIO=4&transport=polling&sid=${sid}` ); expect(pollResponse.status).to.eql(200); const pollContent = await pollResponse.text(); expect(pollContent).to.eql("2"); const pushResponse = await fetch( `${URL}/socket.io/?EIO=4&transport=polling&sid=${sid}`, { method: "post", body: "3", } ); expect(pushResponse.status).to.eql(200); } }); it("should close the session upon ping timeout", async () => { const sid = await initLongPollingSession(); await sleep(PING_INTERVAL + PING_TIMEOUT); const pollResponse = await fetch( `${URL}/socket.io/?EIO=4&transport=polling&sid=${sid}` ); expect(pollResponse.status).to.eql(400); }); }); describe("WebSocket", () => { it("should send ping/pong packets", async () => { const socket = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=websocket` ); await waitFor(socket, "message"); // handshake for (let i = 0; i < 3; i++) { const { data } = await waitFor(socket, "message"); expect(data).to.eql("2"); socket.send("3"); } socket.close(); }); it("should close the session upon ping timeout", async () => { const socket = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=websocket` ); await waitFor(socket, "close"); // handshake }); }); }); describe("close", () => { describe("HTTP long-polling", () => { it("should forcefully close the session", async () => { const sid = await initLongPollingSession(); const [pollResponse] = await Promise.all([ fetch(`${URL}/socket.io/?EIO=4&transport=polling&sid=${sid}`), fetch(`${URL}/socket.io/?EIO=4&transport=polling&sid=${sid}`, { method: "post", body: "1", }), ]); expect(pollResponse.status).to.eql(200); const pullContent = await pollResponse.text(); expect(pullContent).to.eql("6"); const pollResponse2 = await fetch( `${URL}/socket.io/?EIO=4&transport=polling&sid=${sid}` ); expect(pollResponse2.status).to.eql(400); }); }); describe("WebSocket", () => { it("should forcefully close the session", async () => { const socket = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=websocket` ); await waitFor(socket, "message"); // handshake socket.send("1"); await waitFor(socket, "close"); }); }); }); describe("upgrade", () => { it("should successfully upgrade from HTTP long-polling to WebSocket", async () => { const sid = await initLongPollingSession(); const socket = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=websocket&sid=${sid}` ); await waitFor(socket, "open"); // send probe socket.send("2probe"); const probeResponse = await waitFor(socket, "message"); expect(probeResponse.data).to.eql("3probe"); // complete upgrade socket.send("5"); }); it("should ignore HTTP requests with same sid after upgrade", async () => { const sid = await initLongPollingSession(); const socket = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=websocket&sid=${sid}` ); await waitFor(socket, "open"); socket.send("2probe"); await waitFor(socket, "message"); // "3probe" socket.send("5"); const pollResponse = await fetch( `${URL}/socket.io/?EIO=4&transport=polling&sid=${sid}` ); expect(pollResponse.status).to.eql(400); }); it("should ignore WebSocket connection with same sid after upgrade", async () => { const sid = await initLongPollingSession(); const socket = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=websocket&sid=${sid}` ); await waitFor(socket, "open"); socket.send("2probe"); await waitFor(socket, "message"); // "3probe" socket.send("5"); const socket2 = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=websocket&sid=${sid}` ); await waitFor(socket2, "close"); }); }); }); describe("Socket.IO protocol", () => { describe("connect", () => { it("should allow connection to the main namespace", async () => { const socket = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=websocket` ); await waitFor(socket, "message"); // Engine.IO handshake socket.send("40"); const { data } = await waitFor(socket, "message"); expect(data).to.startsWith("40"); const handshake = JSON.parse(data.substring(2)); expect(handshake).to.have.all.keys("sid"); expect(handshake.sid).to.be.a("string"); const authPacket = await waitFor(socket, "message"); expect(authPacket.data).to.eql('42["auth",{}]'); }); it("should allow connection to the main namespace with a payload", async () => { const socket = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=websocket` ); await waitFor(socket, "message"); // Engine.IO handshake socket.send('40{"token":"123"}'); const { data } = await waitFor(socket, "message"); expect(data).to.startsWith("40"); const handshake = JSON.parse(data.substring(2)); expect(handshake).to.have.all.keys("sid"); expect(handshake.sid).to.be.a("string"); const authPacket = await waitFor(socket, "message"); expect(authPacket.data).to.eql('42["auth",{"token":"123"}]'); }); it("should allow connection to a custom namespace", async () => { const socket = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=websocket` ); await waitFor(socket, "message"); // Engine.IO handshake socket.send("40/custom,"); const { data } = await waitFor(socket, "message"); expect(data).to.startsWith("40/custom,"); const handshake = JSON.parse(data.substring(10)); expect(handshake).to.have.all.keys("sid"); expect(handshake.sid).to.be.a("string"); const authPacket = await waitFor(socket, "message"); expect(authPacket.data).to.eql('42/custom,["auth",{}]'); }); it("should allow connection to a custom namespace with a payload", async () => { const socket = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=websocket` ); await waitFor(socket, "message"); // Engine.IO handshake socket.send('40/custom,{"token":"abc"}'); const { data } = await waitFor(socket, "message"); expect(data).to.startsWith("40/custom,"); const handshake = JSON.parse(data.substring(10)); expect(handshake).to.have.all.keys("sid"); expect(handshake.sid).to.be.a("string"); const authPacket = await waitFor(socket, "message"); expect(authPacket.data).to.eql('42/custom,["auth",{"token":"abc"}]'); }); it("should disallow connection to an unknown namespace", async () => { const socket = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=websocket` ); await waitFor(socket, "message"); // Engine.IO handshake socket.send("40/random"); const { data } = await waitFor(socket, "message"); expect(data).to.eql('44/random,{"message":"Invalid namespace"}'); }); it("should disallow connection with an invalid handshake", async () => { const socket = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=websocket` ); await waitFor(socket, "message"); // Engine.IO handshake socket.send("4abc"); await waitFor(socket, "close"); }); it("should close the connection if no handshake is received", async () => { const socket = createWebSocket( `${WS_URL}/socket.io/?EIO=4&transport=websocket` ); await waitFor(socket, "close"); }); }); describe("disconnect", () => { it("should disconnect from the main namespace", async () => { const socket = await initSocketIOConnection(); socket.send("41"); const { data } = await waitFor(socket, "message"); expect(data).to.eql("2"); }); it("should connect then disconnect from a custom namespace", async () => { const socket = await initSocketIOConnection(); await waitFor(socket, "message"); // ping socket.send("40/custom"); await waitFor(socket, "message"); // Socket.IO handshake await waitFor(socket, "message"); // auth packet socket.send("41/custom"); socket.send('42["message","message to main namespace"]'); const { data } = await waitFor(socket, "message"); expect(data).to.eql('42["message-back","message to main namespace"]'); }); }); describe("message", () => { it("should send a plain-text packet", async () => { const socket = await initSocketIOConnection(); socket.send('42["message",1,"2",{"3":[true]}]'); const { data } = await waitFor(socket, "message"); expect(data).to.eql('42["message-back",1,"2",{"3":[true]}]'); }); it("should send a packet with binary attachments", async () => { const socket = await initSocketIOConnection(); socket.send( '452-["message",{"_placeholder":true,"num":0},{"_placeholder":true,"num":1}]' ); socket.send(Uint8Array.from([1, 2, 3])); socket.send(Uint8Array.from([4, 5, 6])); const packets = await waitForPackets(socket, 3); expect(packets[0]).to.eql( '452-["message-back",{"_placeholder":true,"num":0},{"_placeholder":true,"num":1}]' ); expect(packets[1]).to.eql(Uint8Array.from([1, 2, 3]).buffer); expect(packets[2]).to.eql(Uint8Array.from([4, 5, 6]).buffer); socket.close(); }); it("should send a plain-text packet with an ack", async () => { const socket = await initSocketIOConnection(); socket.send('42456["message-with-ack",1,"2",{"3":[false]}]'); const { data } = await waitFor(socket, "message"); expect(data).to.eql('43456[1,"2",{"3":[false]}]'); }); it("should send a packet with binary attachments and an ack", async () => { const socket = await initSocketIOConnection(); socket.send( '452-789["message-with-ack",{"_placeholder":true,"num":0},{"_placeholder":true,"num":1}]' ); socket.send(Uint8Array.from([1, 2, 3])); socket.send(Uint8Array.from([4, 5, 6])); const packets = await waitForPackets(socket, 3); expect(packets[0]).to.eql( '462-789[{"_placeholder":true,"num":0},{"_placeholder":true,"num":1}]' ); expect(packets[1]).to.eql(Uint8Array.from([1, 2, 3]).buffer); expect(packets[2]).to.eql(Uint8Array.from([4, 5, 6]).buffer); socket.close(); }); it("should close the connection upon invalid format (unknown packet type)", async () => { const socket = await initSocketIOConnection(); socket.send("4abc"); await waitFor(socket, "close"); }); it("should close the connection upon invalid format (invalid payload format)", async () => { const socket = await initSocketIOConnection(); socket.send("42{}"); await waitFor(socket, "close"); }); it("should close the connection upon invalid format (invalid ack id)", async () => { const socket = await initSocketIOConnection(); socket.send('42abc["message-with-ack",1,"2",{"3":[false]}]'); await waitFor(socket, "close"); }); }); });