From 6458b2bef171aa0d7dea198297608ea2ed4b1db9 Mon Sep 17 00:00:00 2001 From: Damien Arrachequesne Date: Fri, 24 Mar 2023 11:17:29 +0100 Subject: [PATCH] docs(example): basic WebSocket-only client --- examples/basic-websocket-client/README.md | 18 ++ .../bundle/socket.io.min.js | 1 + .../check-bundle-size.js | 17 ++ examples/basic-websocket-client/package.json | 18 ++ .../basic-websocket-client/rollup.config.js | 10 + examples/basic-websocket-client/src/index.js | 273 ++++++++++++++++++ examples/basic-websocket-client/test/index.js | 162 +++++++++++ 7 files changed, 499 insertions(+) create mode 100644 examples/basic-websocket-client/README.md create mode 100644 examples/basic-websocket-client/bundle/socket.io.min.js create mode 100644 examples/basic-websocket-client/check-bundle-size.js create mode 100644 examples/basic-websocket-client/package.json create mode 100644 examples/basic-websocket-client/rollup.config.js create mode 100644 examples/basic-websocket-client/src/index.js create mode 100644 examples/basic-websocket-client/test/index.js diff --git a/examples/basic-websocket-client/README.md b/examples/basic-websocket-client/README.md new file mode 100644 index 0000000000..514b8d289b --- /dev/null +++ b/examples/basic-websocket-client/README.md @@ -0,0 +1,18 @@ +# Basic Socket.IO client + +Please check the associated guide: https://socket.io/how-to/build-a-basic-client + +Content: + +``` +├── bundle +│ └── socket.io.min.js +├── src +│ └── index.js +├── test +│ └── index.js +├── check-bundle-size.js +├── package.json +├── README.md +└── rollup.config.js +``` diff --git a/examples/basic-websocket-client/bundle/socket.io.min.js b/examples/basic-websocket-client/bundle/socket.io.min.js new file mode 100644 index 0000000000..fa1b109fde --- /dev/null +++ b/examples/basic-websocket-client/bundle/socket.io.min.js @@ -0,0 +1 @@ +class e{#e=new Map;on(e,t){let s=this.#e.get(e);s||this.#e.set(e,s=[]),s.push(t)}emit(e,...t){const s=this.#e.get(e);if(s)for(const e of s)e.apply(null,t)}}const t="0",s="1",n="2",i="3",o="4",r={CONNECT:0,DISCONNECT:1,EVENT:2};function c(){}class a extends e{id;connected=!1;#t;#s;#n;#i;#o;#r=[];#c;#a=!0;constructor(e,t){super(),this.#t=e,this.#s=Object.assign({path:"/socket.io/",reconnectionDelay:2e3},t),this.#h()}#h(){this.#n=new WebSocket(this.#u()),this.#n.onmessage=({data:e})=>this.#p(e),this.#n.onerror=c,this.#n.onclose=()=>this.#l("transport close")}#u(){return`${this.#t.replace(/^http/,"ws")}${this.#s.path}?EIO=4&transport=websocket`}#p(e){if("string"==typeof e)switch(e[0]){case t:this.#d(e);break;case s:this.#l("transport close");break;case n:this.#T(),this.#m(i);break;case o:let c;try{c=function(e){let t=1;const s={type:parseInt(e.charAt(t++),10)};e.charAt(t)&&(s.data=JSON.parse(e.substring(t)));if(!function(e){switch(e.type){case r.CONNECT:return"object"==typeof e.data;case r.DISCONNECT:return void 0===e.data;case r.EVENT:{const t=e.data;return Array.isArray(t)&&t.length>0&&"string"==typeof t[0]}default:return!1}}(s))throw new Error("invalid format");return s}(e)}catch(e){return this.#l("parse error")}this.#f(c);break;default:this.#l("parse error")}}#d(e){let t;try{t=JSON.parse(e.substring(1))}catch(e){return this.#l("parse error")}this.#o=t.pingInterval+t.pingTimeout,this.#T(),this.#C()}#f(e){switch(e.type){case r.CONNECT:this.#g(e);break;case r.DISCONNECT:this.#a=!1,this.#l("io server disconnect");break;case r.EVENT:super.emit.apply(this,e.data);break;default:this.#l("parse error")}}#g(e){this.id=e.data.sid,this.connected=!0,this.#r.forEach((e=>this.#y(e))),this.#r.slice(0),super.emit("connect")}#l(e){this.#n&&(this.#n.onclose=c,this.#n.close()),clearTimeout(this.#i),clearTimeout(this.#c),this.connected?(this.connected=!1,this.id=void 0,super.emit("disconnect",e)):super.emit("connect_error",e),this.#a&&(this.#c=setTimeout((()=>this.#h()),this.#s.reconnectionDelay))}#T(){clearTimeout(this.#i),this.#i=setTimeout((()=>{this.#l("ping timeout")}),this.#o)}#m(e){this.#n.readyState===WebSocket.OPEN&&this.#n.send(e)}#y(e){this.#m(o+function(e){let t=""+e.type;e.data&&(t+=JSON.stringify(e.data));return t}(e))}#C(){this.#y({type:r.CONNECT})}emit(...e){const t={type:r.EVENT,data:e};this.connected?this.#y(t):this.#r.push(t)}disconnect(){this.#a=!1,this.#l("io client disconnect")}}function h(e,t){return"string"!=typeof e&&(t=e,e=location.origin),new a(e,t)}export{h as io}; diff --git a/examples/basic-websocket-client/check-bundle-size.js b/examples/basic-websocket-client/check-bundle-size.js new file mode 100644 index 0000000000..748b4acbf4 --- /dev/null +++ b/examples/basic-websocket-client/check-bundle-size.js @@ -0,0 +1,17 @@ +import { rollup } from "rollup"; +import terser from "@rollup/plugin-terser"; +import { brotliCompressSync } from "node:zlib"; + +const rollupBuild = await rollup({ + input: "./src/index.js" +}); + +const rollupOutput = await rollupBuild.generate({ + format: "esm", + plugins: [terser()], +}); + +const bundleAsString = rollupOutput.output[0].code; +const brotliedBundle = brotliCompressSync(Buffer.from(bundleAsString)); + +console.log(`Bundle size: ${brotliedBundle.length} B`); diff --git a/examples/basic-websocket-client/package.json b/examples/basic-websocket-client/package.json new file mode 100644 index 0000000000..a6f807c266 --- /dev/null +++ b/examples/basic-websocket-client/package.json @@ -0,0 +1,18 @@ +{ + "type": "module", + "devDependencies": { + "@rollup/plugin-terser": "^0.4.0", + "chai": "^4.3.7", + "mocha": "^10.2.0", + "prettier": "^2.8.4", + "rollup": "^3.20.2", + "socket.io": "^4.6.1", + "ws": "^8.13.0" + }, + "scripts": { + "bundle": "rollup -c", + "check-bundle-size": "node check-bundle-size.js", + "format": "prettier -w src/ test/", + "test": "mocha" + } +} diff --git a/examples/basic-websocket-client/rollup.config.js b/examples/basic-websocket-client/rollup.config.js new file mode 100644 index 0000000000..b50659991c --- /dev/null +++ b/examples/basic-websocket-client/rollup.config.js @@ -0,0 +1,10 @@ +import terser from "@rollup/plugin-terser"; + +export default { + input: "./src/index.js", + output: { + file: "./bundle/socket.io.min.js", + format: "esm", + plugins: [terser()], + } +}; diff --git a/examples/basic-websocket-client/src/index.js b/examples/basic-websocket-client/src/index.js new file mode 100644 index 0000000000..2af47a090f --- /dev/null +++ b/examples/basic-websocket-client/src/index.js @@ -0,0 +1,273 @@ +class EventEmitter { + #listeners = new Map(); + + on(event, listener) { + let listeners = this.#listeners.get(event); + if (!listeners) { + this.#listeners.set(event, (listeners = [])); + } + listeners.push(listener); + } + + emit(event, ...args) { + const listeners = this.#listeners.get(event); + if (listeners) { + for (const listener of listeners) { + listener.apply(null, args); + } + } + } +} + +const EIOPacketType = { + OPEN: "0", + CLOSE: "1", + PING: "2", + PONG: "3", + MESSAGE: "4", +}; + +const SIOPacketType = { + CONNECT: 0, + DISCONNECT: 1, + EVENT: 2, +}; + +function noop() {} + +class Socket extends EventEmitter { + id; + connected = false; + + #uri; + #opts; + #ws; + #pingTimeoutTimer; + #pingTimeoutDelay; + #sendBuffer = []; + #reconnectTimer; + #shouldReconnect = true; + + constructor(uri, opts) { + super(); + this.#uri = uri; + this.#opts = Object.assign( + { + path: "/socket.io/", + reconnectionDelay: 2000, + }, + opts + ); + this.#open(); + } + + #open() { + this.#ws = new WebSocket(this.#createUrl()); + this.#ws.onmessage = ({ data }) => this.#onMessage(data); + // dummy handler for Node.js + this.#ws.onerror = noop; + this.#ws.onclose = () => this.#onClose("transport close"); + } + + #createUrl() { + const uri = this.#uri.replace(/^http/, "ws"); + const queryParams = "?EIO=4&transport=websocket"; + return `${uri}${this.#opts.path}${queryParams}`; + } + + #onMessage(data) { + if (typeof data !== "string") { + // TODO handle binary payloads + return; + } + + switch (data[0]) { + case EIOPacketType.OPEN: + this.#onOpen(data); + break; + + case EIOPacketType.CLOSE: + this.#onClose("transport close"); + break; + + case EIOPacketType.PING: + this.#resetPingTimeout(); + this.#send(EIOPacketType.PONG); + break; + + case EIOPacketType.MESSAGE: + let packet; + try { + packet = decode(data); + } catch (e) { + return this.#onClose("parse error"); + } + this.#onPacket(packet); + break; + + default: + this.#onClose("parse error"); + break; + } + } + + #onOpen(data) { + let handshake; + try { + handshake = JSON.parse(data.substring(1)); + } catch (e) { + return this.#onClose("parse error"); + } + this.#pingTimeoutDelay = handshake.pingInterval + handshake.pingTimeout; + this.#resetPingTimeout(); + this.#doConnect(); + } + + #onPacket(packet) { + switch (packet.type) { + case SIOPacketType.CONNECT: + this.#onConnect(packet); + break; + + case SIOPacketType.DISCONNECT: + this.#shouldReconnect = false; + this.#onClose("io server disconnect"); + break; + + case SIOPacketType.EVENT: + super.emit.apply(this, packet.data); + break; + + default: + this.#onClose("parse error"); + break; + } + } + + #onConnect(packet) { + this.id = packet.data.sid; + this.connected = true; + + this.#sendBuffer.forEach((packet) => this.#sendPacket(packet)); + this.#sendBuffer.slice(0); + + super.emit("connect"); + } + + #onClose(reason) { + if (this.#ws) { + this.#ws.onclose = noop; + this.#ws.close(); + } + + clearTimeout(this.#pingTimeoutTimer); + clearTimeout(this.#reconnectTimer); + + if (this.connected) { + this.connected = false; + this.id = undefined; + super.emit("disconnect", reason); + } else { + super.emit("connect_error", reason); + } + + if (this.#shouldReconnect) { + this.#reconnectTimer = setTimeout( + () => this.#open(), + this.#opts.reconnectionDelay + ); + } + } + + #resetPingTimeout() { + clearTimeout(this.#pingTimeoutTimer); + this.#pingTimeoutTimer = setTimeout(() => { + this.#onClose("ping timeout"); + }, this.#pingTimeoutDelay); + } + + #send(data) { + if (this.#ws.readyState === WebSocket.OPEN) { + this.#ws.send(data); + } + } + + #sendPacket(packet) { + this.#send(EIOPacketType.MESSAGE + encode(packet)); + } + + #doConnect() { + this.#sendPacket({ type: SIOPacketType.CONNECT }); + } + + emit(...args) { + const packet = { + type: SIOPacketType.EVENT, + data: args, + }; + + if (this.connected) { + this.#sendPacket(packet); + } else { + this.#sendBuffer.push(packet); + } + } + + disconnect() { + this.#shouldReconnect = false; + this.#onClose("io client disconnect"); + } +} + +function encode(packet) { + let output = "" + packet.type; + + if (packet.data) { + output += JSON.stringify(packet.data); + } + + return output; +} + +function decode(data) { + let i = 1; // skip "4" prefix + + const packet = { + type: parseInt(data.charAt(i++), 10), + }; + + if (data.charAt(i)) { + packet.data = JSON.parse(data.substring(i)); + } + + if (!isPacketValid(packet)) { + throw new Error("invalid format"); + } + + return packet; +} + +function isPacketValid(packet) { + switch (packet.type) { + case SIOPacketType.CONNECT: + return typeof packet.data === "object"; + case SIOPacketType.DISCONNECT: + return packet.data === undefined; + case SIOPacketType.EVENT: { + const args = packet.data; + return ( + Array.isArray(args) && args.length > 0 && typeof args[0] === "string" + ); + } + default: + return false; + } +} + +export function io(uri, opts) { + if (typeof uri !== "string") { + opts = uri; + uri = location.origin; + } + return new Socket(uri, opts); +} diff --git a/examples/basic-websocket-client/test/index.js b/examples/basic-websocket-client/test/index.js new file mode 100644 index 0000000000..8bb1e72de6 --- /dev/null +++ b/examples/basic-websocket-client/test/index.js @@ -0,0 +1,162 @@ +import { createServer } from "node:http"; +import { io as ioc } from "../src/index.js"; +import { WebSocket } from "ws"; +import { Server } from "socket.io"; +import { expect } from "chai"; + +// @ts-ignore for Node.js +globalThis.WebSocket = WebSocket; + +function waitFor(emitter, eventName) { + return new Promise((resolve) => { + emitter.on(eventName, resolve); + }); +} + +function sleep(delay) { + return new Promise((resolve) => { + setTimeout(resolve, delay); + }); +} + +describe("basic client", () => { + let io, port, socket; + + beforeEach(() => { + const httpServer = createServer(); + io = new Server(httpServer); + + httpServer.listen(0); + port = httpServer.address().port; + }); + + afterEach(() => { + io.close(); + socket.disconnect(); + }); + + it("should connect", async () => { + socket = ioc(`ws://localhost:${port}`); + + await waitFor(socket, "connect"); + + expect(socket.connected).to.eql(true); + expect(socket.id).to.be.a("string"); + }); + + it("should connect with 'http://' scheme", async () => { + socket = ioc(`http://localhost:${port}`); + + await waitFor(socket, "connect"); + }); + + it("should connect with URL inferred from 'window.location'", async () => { + globalThis.location = { + origin: `http://localhost:${port}`, + }; + socket = ioc(); + + await waitFor(socket, "connect"); + }); + + it("should fail to connect to an invalid URL", async () => { + socket = ioc(`http://localhost:4321`); + + await waitFor(socket, "connect_error"); + }); + + it("should receive an event", async () => { + io.on("connection", (socket) => { + socket.emit("foo", 123); + }); + + socket = ioc(`ws://localhost:${port}`); + + const value = await waitFor(socket, "foo"); + + expect(value).to.eql(123); + }); + + it("should send an event (not buffered)", async () => { + socket = ioc(`ws://localhost:${port}`); + + const [serverSocket] = await Promise.all([ + waitFor(io, "connection"), + waitFor(socket, "connect"), + ]); + + socket.emit("foo", 456); + + const value = await waitFor(serverSocket, "foo"); + + expect(value).to.eql(456); + }); + + it("should send an event (buffered)", async () => { + socket = ioc(`ws://localhost:${port}`); + + socket.emit("foo", 789); + + const [serverSocket] = await Promise.all([ + waitFor(io, "connection"), + waitFor(socket, "connect"), + ]); + + const value = await waitFor(serverSocket, "foo"); + + expect(value).to.eql(789); + }); + + it("should reconnect", async () => { + socket = ioc(`ws://localhost:${port}`, { + reconnectionDelay: 50, + }); + + await waitFor(socket, "connect"); + + io.close(); + + await waitFor(socket, "disconnect"); + + io.listen(port); + + await waitFor(socket, "connect"); + }); + + it("should respond to PING packets", async () => { + io.engine.opts.pingInterval = 50; + io.engine.opts.pingTimeout = 20; + + socket = ioc(`ws://localhost:${port}`); + + await waitFor(socket, "connect"); + + await sleep(500); + + expect(socket.connected).to.eql(true); + }); + + it("should disconnect (client side)", async () => { + socket = ioc(`ws://localhost:${port}`); + + await waitFor(socket, "connect"); + + socket.disconnect(); + + expect(socket.connected).to.eql(false); + expect(socket.id).to.eql(undefined); + }); + + it("should disconnect (server side)", async () => { + socket = ioc(`ws://localhost:${port}`); + + const [serverSocket] = await Promise.all([ + waitFor(io, "connection"), + waitFor(socket, "connect"), + ]); + + serverSocket.disconnect(); + + await waitFor(socket, "disconnect"); + }); +});