Skip to content

Commit fb6f0bc

Browse files
darrachequesnehaneenmahd
authored andcommitted
docs(example): basic WebSocket-only client
1 parent 5f59826 commit fb6f0bc

File tree

7 files changed

+499
-0
lines changed

7 files changed

+499
-0
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Basic Socket.IO client
2+
3+
Please check the associated guide: https://socket.io/how-to/build-a-basic-client
4+
5+
Content:
6+
7+
```
8+
├── bundle
9+
│ └── socket.io.min.js
10+
├── src
11+
│ └── index.js
12+
├── test
13+
│ └── index.js
14+
├── check-bundle-size.js
15+
├── package.json
16+
├── README.md
17+
└── rollup.config.js
18+
```

examples/basic-websocket-client/bundle/socket.io.min.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { rollup } from "rollup";
2+
import terser from "@rollup/plugin-terser";
3+
import { brotliCompressSync } from "node:zlib";
4+
5+
const rollupBuild = await rollup({
6+
input: "./src/index.js"
7+
});
8+
9+
const rollupOutput = await rollupBuild.generate({
10+
format: "esm",
11+
plugins: [terser()],
12+
});
13+
14+
const bundleAsString = rollupOutput.output[0].code;
15+
const brotliedBundle = brotliCompressSync(Buffer.from(bundleAsString));
16+
17+
console.log(`Bundle size: ${brotliedBundle.length} B`);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"type": "module",
3+
"devDependencies": {
4+
"@rollup/plugin-terser": "^0.4.0",
5+
"chai": "^4.3.7",
6+
"mocha": "^10.2.0",
7+
"prettier": "^2.8.4",
8+
"rollup": "^3.20.2",
9+
"socket.io": "^4.6.1",
10+
"ws": "^8.13.0"
11+
},
12+
"scripts": {
13+
"bundle": "rollup -c",
14+
"check-bundle-size": "node check-bundle-size.js",
15+
"format": "prettier -w src/ test/",
16+
"test": "mocha"
17+
}
18+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import terser from "@rollup/plugin-terser";
2+
3+
export default {
4+
input: "./src/index.js",
5+
output: {
6+
file: "./bundle/socket.io.min.js",
7+
format: "esm",
8+
plugins: [terser()],
9+
}
10+
};
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
class EventEmitter {
2+
#listeners = new Map();
3+
4+
on(event, listener) {
5+
let listeners = this.#listeners.get(event);
6+
if (!listeners) {
7+
this.#listeners.set(event, (listeners = []));
8+
}
9+
listeners.push(listener);
10+
}
11+
12+
emit(event, ...args) {
13+
const listeners = this.#listeners.get(event);
14+
if (listeners) {
15+
for (const listener of listeners) {
16+
listener.apply(null, args);
17+
}
18+
}
19+
}
20+
}
21+
22+
const EIOPacketType = {
23+
OPEN: "0",
24+
CLOSE: "1",
25+
PING: "2",
26+
PONG: "3",
27+
MESSAGE: "4",
28+
};
29+
30+
const SIOPacketType = {
31+
CONNECT: 0,
32+
DISCONNECT: 1,
33+
EVENT: 2,
34+
};
35+
36+
function noop() {}
37+
38+
class Socket extends EventEmitter {
39+
id;
40+
connected = false;
41+
42+
#uri;
43+
#opts;
44+
#ws;
45+
#pingTimeoutTimer;
46+
#pingTimeoutDelay;
47+
#sendBuffer = [];
48+
#reconnectTimer;
49+
#shouldReconnect = true;
50+
51+
constructor(uri, opts) {
52+
super();
53+
this.#uri = uri;
54+
this.#opts = Object.assign(
55+
{
56+
path: "/socket.io/",
57+
reconnectionDelay: 2000,
58+
},
59+
opts
60+
);
61+
this.#open();
62+
}
63+
64+
#open() {
65+
this.#ws = new WebSocket(this.#createUrl());
66+
this.#ws.onmessage = ({ data }) => this.#onMessage(data);
67+
// dummy handler for Node.js
68+
this.#ws.onerror = noop;
69+
this.#ws.onclose = () => this.#onClose("transport close");
70+
}
71+
72+
#createUrl() {
73+
const uri = this.#uri.replace(/^http/, "ws");
74+
const queryParams = "?EIO=4&transport=websocket";
75+
return `${uri}${this.#opts.path}${queryParams}`;
76+
}
77+
78+
#onMessage(data) {
79+
if (typeof data !== "string") {
80+
// TODO handle binary payloads
81+
return;
82+
}
83+
84+
switch (data[0]) {
85+
case EIOPacketType.OPEN:
86+
this.#onOpen(data);
87+
break;
88+
89+
case EIOPacketType.CLOSE:
90+
this.#onClose("transport close");
91+
break;
92+
93+
case EIOPacketType.PING:
94+
this.#resetPingTimeout();
95+
this.#send(EIOPacketType.PONG);
96+
break;
97+
98+
case EIOPacketType.MESSAGE:
99+
let packet;
100+
try {
101+
packet = decode(data);
102+
} catch (e) {
103+
return this.#onClose("parse error");
104+
}
105+
this.#onPacket(packet);
106+
break;
107+
108+
default:
109+
this.#onClose("parse error");
110+
break;
111+
}
112+
}
113+
114+
#onOpen(data) {
115+
let handshake;
116+
try {
117+
handshake = JSON.parse(data.substring(1));
118+
} catch (e) {
119+
return this.#onClose("parse error");
120+
}
121+
this.#pingTimeoutDelay = handshake.pingInterval + handshake.pingTimeout;
122+
this.#resetPingTimeout();
123+
this.#doConnect();
124+
}
125+
126+
#onPacket(packet) {
127+
switch (packet.type) {
128+
case SIOPacketType.CONNECT:
129+
this.#onConnect(packet);
130+
break;
131+
132+
case SIOPacketType.DISCONNECT:
133+
this.#shouldReconnect = false;
134+
this.#onClose("io server disconnect");
135+
break;
136+
137+
case SIOPacketType.EVENT:
138+
super.emit.apply(this, packet.data);
139+
break;
140+
141+
default:
142+
this.#onClose("parse error");
143+
break;
144+
}
145+
}
146+
147+
#onConnect(packet) {
148+
this.id = packet.data.sid;
149+
this.connected = true;
150+
151+
this.#sendBuffer.forEach((packet) => this.#sendPacket(packet));
152+
this.#sendBuffer.slice(0);
153+
154+
super.emit("connect");
155+
}
156+
157+
#onClose(reason) {
158+
if (this.#ws) {
159+
this.#ws.onclose = noop;
160+
this.#ws.close();
161+
}
162+
163+
clearTimeout(this.#pingTimeoutTimer);
164+
clearTimeout(this.#reconnectTimer);
165+
166+
if (this.connected) {
167+
this.connected = false;
168+
this.id = undefined;
169+
super.emit("disconnect", reason);
170+
} else {
171+
super.emit("connect_error", reason);
172+
}
173+
174+
if (this.#shouldReconnect) {
175+
this.#reconnectTimer = setTimeout(
176+
() => this.#open(),
177+
this.#opts.reconnectionDelay
178+
);
179+
}
180+
}
181+
182+
#resetPingTimeout() {
183+
clearTimeout(this.#pingTimeoutTimer);
184+
this.#pingTimeoutTimer = setTimeout(() => {
185+
this.#onClose("ping timeout");
186+
}, this.#pingTimeoutDelay);
187+
}
188+
189+
#send(data) {
190+
if (this.#ws.readyState === WebSocket.OPEN) {
191+
this.#ws.send(data);
192+
}
193+
}
194+
195+
#sendPacket(packet) {
196+
this.#send(EIOPacketType.MESSAGE + encode(packet));
197+
}
198+
199+
#doConnect() {
200+
this.#sendPacket({ type: SIOPacketType.CONNECT });
201+
}
202+
203+
emit(...args) {
204+
const packet = {
205+
type: SIOPacketType.EVENT,
206+
data: args,
207+
};
208+
209+
if (this.connected) {
210+
this.#sendPacket(packet);
211+
} else {
212+
this.#sendBuffer.push(packet);
213+
}
214+
}
215+
216+
disconnect() {
217+
this.#shouldReconnect = false;
218+
this.#onClose("io client disconnect");
219+
}
220+
}
221+
222+
function encode(packet) {
223+
let output = "" + packet.type;
224+
225+
if (packet.data) {
226+
output += JSON.stringify(packet.data);
227+
}
228+
229+
return output;
230+
}
231+
232+
function decode(data) {
233+
let i = 1; // skip "4" prefix
234+
235+
const packet = {
236+
type: parseInt(data.charAt(i++), 10),
237+
};
238+
239+
if (data.charAt(i)) {
240+
packet.data = JSON.parse(data.substring(i));
241+
}
242+
243+
if (!isPacketValid(packet)) {
244+
throw new Error("invalid format");
245+
}
246+
247+
return packet;
248+
}
249+
250+
function isPacketValid(packet) {
251+
switch (packet.type) {
252+
case SIOPacketType.CONNECT:
253+
return typeof packet.data === "object";
254+
case SIOPacketType.DISCONNECT:
255+
return packet.data === undefined;
256+
case SIOPacketType.EVENT: {
257+
const args = packet.data;
258+
return (
259+
Array.isArray(args) && args.length > 0 && typeof args[0] === "string"
260+
);
261+
}
262+
default:
263+
return false;
264+
}
265+
}
266+
267+
export function io(uri, opts) {
268+
if (typeof uri !== "string") {
269+
opts = uri;
270+
uri = location.origin;
271+
}
272+
return new Socket(uri, opts);
273+
}

0 commit comments

Comments
 (0)