mirror of
https://github.com/socketio/socket.io.git
synced 2026-01-09 15:08:12 -05:00
docs(example): basic WebSocket-only client
This commit is contained in:
18
examples/basic-websocket-client/README.md
Normal file
18
examples/basic-websocket-client/README.md
Normal file
@@ -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
examples/basic-websocket-client/bundle/socket.io.min.js
vendored
Normal file
1
examples/basic-websocket-client/bundle/socket.io.min.js
vendored
Normal file
@@ -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};
|
||||
17
examples/basic-websocket-client/check-bundle-size.js
Normal file
17
examples/basic-websocket-client/check-bundle-size.js
Normal file
@@ -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
examples/basic-websocket-client/package.json
Normal file
18
examples/basic-websocket-client/package.json
Normal file
@@ -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
examples/basic-websocket-client/rollup.config.js
Normal file
10
examples/basic-websocket-client/rollup.config.js
Normal file
@@ -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
examples/basic-websocket-client/src/index.js
Normal file
273
examples/basic-websocket-client/src/index.js
Normal file
@@ -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);
|
||||
}
|
||||
162
examples/basic-websocket-client/test/index.js
Normal file
162
examples/basic-websocket-client/test/index.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user