Skip to content

Commit

Permalink
docs(example): basic WebSocket-only client
Browse files Browse the repository at this point in the history
  • Loading branch information
darrachequesne committed Mar 24, 2023
1 parent b56da8a commit 6458b2b
Show file tree
Hide file tree
Showing 7 changed files with 499 additions and 0 deletions.
18 changes: 18 additions & 0 deletions 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
```
1 change: 1 addition & 0 deletions examples/basic-websocket-client/bundle/socket.io.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions 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`);
18 changes: 18 additions & 0 deletions 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"
}
}
10 changes: 10 additions & 0 deletions 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()],
}
};
273 changes: 273 additions & 0 deletions 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);
}

0 comments on commit 6458b2b

Please sign in to comment.