Skip to content

Commit c0d8c5a

Browse files
feat: add an implementation based on uWebSockets.js
Usage: ```js const { App } = require("uWebSockets.js"); const { Server } = require("socket.io"); const app = new App(); const server = new Server(); server.attachApp(app); app.listen(3000); ``` The Adapter prototype is updated so we can benefit from the publish functionality of uWebSockets.js, so this will apply to all adapters extending the default adapter. Reference: https://github.com/uNetworking/uWebSockets.js Related: - #3601 - socketio/engine.io#578
1 parent fe8730c commit c0d8c5a

File tree

7 files changed

+431
-26
lines changed

7 files changed

+431
-26
lines changed

lib/index.ts

+68
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
Server as Engine,
1010
ServerOptions as EngineOptions,
1111
AttachOptions,
12+
uServer,
1213
} from "engine.io";
1314
import { Client } from "./client";
1415
import { EventEmitter } from "events";
@@ -27,6 +28,7 @@ import {
2728
StrictEventEmitter,
2829
EventNames,
2930
} from "./typed-events";
31+
import { patchAdapter, restoreAdapter, serveFile } from "./uws.js";
3032

3133
const debug = debugModule("socket.io:server");
3234

@@ -344,6 +346,69 @@ export class Server<
344346
return this;
345347
}
346348

349+
public attachApp(app /*: TemplatedApp */, opts: Partial<ServerOptions> = {}) {
350+
// merge the options passed to the Socket.IO server
351+
Object.assign(opts, this.opts);
352+
// set engine.io path to `/socket.io`
353+
opts.path = opts.path || this._path;
354+
355+
// initialize engine
356+
debug("creating uWebSockets.js-based engine with opts %j", opts);
357+
const engine = new uServer(opts);
358+
359+
engine.attach(app, opts);
360+
361+
// bind to engine events
362+
this.bind(engine);
363+
364+
if (this._serveClient) {
365+
// attach static file serving
366+
app.get(`${this._path}/*`, (res, req) => {
367+
if (!this.clientPathRegex.test(req.getUrl())) {
368+
req.setYield(true);
369+
return;
370+
}
371+
372+
const filename = req
373+
.getUrl()
374+
.replace(this._path, "")
375+
.replace(/\?.*$/, "")
376+
.replace(/^\//, "");
377+
const isMap = dotMapRegex.test(filename);
378+
const type = isMap ? "map" : "source";
379+
380+
// Per the standard, ETags must be quoted:
381+
// https://tools.ietf.org/html/rfc7232#section-2.3
382+
const expectedEtag = '"' + clientVersion + '"';
383+
const weakEtag = "W/" + expectedEtag;
384+
385+
const etag = req.getHeader("if-none-match");
386+
if (etag) {
387+
if (expectedEtag === etag || weakEtag === etag) {
388+
debug("serve client %s 304", type);
389+
res.writeStatus("304 Not Modified");
390+
res.end();
391+
return;
392+
}
393+
}
394+
395+
debug("serve client %s", type);
396+
397+
res.writeHeader("cache-control", "public, max-age=0");
398+
res.writeHeader(
399+
"content-type",
400+
"application/" + (isMap ? "json" : "javascript")
401+
);
402+
res.writeHeader("etag", expectedEtag);
403+
404+
const filepath = path.join(__dirname, "../client-dist/", filename);
405+
serveFile(res, filepath);
406+
});
407+
}
408+
409+
patchAdapter(app);
410+
}
411+
347412
/**
348413
* Initialize engine
349414
*
@@ -562,6 +627,9 @@ export class Server<
562627

563628
this.engine.close();
564629

630+
// restore the Adapter prototype
631+
restoreAdapter();
632+
565633
if (this.httpServer) {
566634
this.httpServer.close(fn);
567635
} else {

lib/socket.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Packet, PacketType } from "socket.io-parser";
2-
import url = require("url");
32
import debugModule from "debug";
43
import type { Server } from "./index";
54
import {
@@ -184,7 +183,8 @@ export class Socket<
184183
secure: !!this.request.connection.encrypted,
185184
issued: +new Date(),
186185
url: this.request.url!,
187-
query: url.parse(this.request.url!, true).query,
186+
// @ts-ignore
187+
query: this.request._query,
188188
auth,
189189
};
190190
}

lib/uws.ts

+164
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { Adapter, Room } from "socket.io-adapter";
2+
import type { WebSocket } from "uWebSockets.js";
3+
import type { Socket } from "./socket.js";
4+
import { createReadStream, statSync } from "fs";
5+
import debugModule from "debug";
6+
7+
const debug = debugModule("socket.io:adapter-uws");
8+
9+
const SEPARATOR = "\x1f"; // see https://en.wikipedia.org/wiki/Delimiter#ASCII_delimited_text
10+
11+
const { addAll, del, broadcast } = Adapter.prototype;
12+
13+
export function patchAdapter(app /* : TemplatedApp */) {
14+
Adapter.prototype.addAll = function (id, rooms) {
15+
const isNew = !this.sids.has(id);
16+
addAll.call(this, id, rooms);
17+
const socket: Socket = this.nsp.sockets.get(id);
18+
if (!socket) {
19+
return;
20+
}
21+
if (socket.conn.transport.name === "websocket") {
22+
subscribe(this.nsp.name, socket, isNew, rooms);
23+
return;
24+
}
25+
if (isNew) {
26+
socket.conn.on("upgrade", () => {
27+
const rooms = this.sids.get(id);
28+
subscribe(this.nsp.name, socket, isNew, rooms);
29+
});
30+
}
31+
};
32+
33+
Adapter.prototype.del = function (id, room) {
34+
del.call(this, id, room);
35+
const socket: Socket = this.nsp.sockets.get(id);
36+
if (socket && socket.conn.transport.name === "websocket") {
37+
// @ts-ignore
38+
const sessionId = socket.conn.id;
39+
// @ts-ignore
40+
const websocket: WebSocket = socket.conn.transport.socket;
41+
const topic = `${this.nsp.name}${SEPARATOR}${room}`;
42+
debug("unsubscribe connection %s from topic %s", sessionId, topic);
43+
websocket.unsubscribe(topic);
44+
}
45+
};
46+
47+
Adapter.prototype.broadcast = function (packet, opts) {
48+
const useFastPublish = opts.rooms.size <= 1 && opts.except!.size === 0;
49+
if (!useFastPublish) {
50+
broadcast.call(this, packet, opts);
51+
return;
52+
}
53+
54+
const flags = opts.flags || {};
55+
const basePacketOpts = {
56+
preEncoded: true,
57+
volatile: flags.volatile,
58+
compress: flags.compress,
59+
};
60+
61+
packet.nsp = this.nsp.name;
62+
const encodedPackets = this.encoder.encode(packet);
63+
64+
const topic =
65+
opts.rooms.size === 0
66+
? this.nsp.name
67+
: `${this.nsp.name}${SEPARATOR}${opts.rooms.keys().next().value}`;
68+
debug("fast publish to %s", topic);
69+
70+
// fast publish for clients connected with WebSocket
71+
encodedPackets.forEach((encodedPacket) => {
72+
const isBinary = typeof encodedPacket !== "string";
73+
// "4" being the message type in the Engine.IO protocol, see https://github.com/socketio/engine.io-protocol
74+
app.publish(
75+
topic,
76+
isBinary ? encodedPacket : "4" + encodedPacket,
77+
isBinary
78+
);
79+
});
80+
81+
this.apply(opts, (socket) => {
82+
if (socket.conn.transport.name !== "websocket") {
83+
// classic publish for clients connected with HTTP long-polling
84+
for (let i = 0; i < encodedPackets.length; i++) {
85+
socket.client.writeToEngine(encodedPackets[i], basePacketOpts);
86+
}
87+
}
88+
});
89+
};
90+
}
91+
92+
function subscribe(
93+
namespaceName: string,
94+
socket: Socket,
95+
isNew: boolean,
96+
rooms: Set<Room>
97+
) {
98+
// @ts-ignore
99+
const sessionId = socket.conn.id;
100+
// @ts-ignore
101+
const websocket: WebSocket = socket.conn.transport.socket;
102+
if (isNew) {
103+
debug("subscribe connection %s to topic %s", sessionId, namespaceName);
104+
websocket.subscribe(namespaceName);
105+
}
106+
rooms.forEach((room) => {
107+
const topic = `${namespaceName}${SEPARATOR}${room}`; // '#' can be used as wildcard
108+
debug("subscribe connection %s to topic %s", sessionId, topic);
109+
websocket.subscribe(topic);
110+
});
111+
}
112+
113+
export function restoreAdapter() {
114+
Adapter.prototype.addAll = addAll;
115+
Adapter.prototype.del = del;
116+
Adapter.prototype.broadcast = broadcast;
117+
}
118+
119+
const toArrayBuffer = (buffer: Buffer) => {
120+
const { buffer: arrayBuffer, byteOffset, byteLength } = buffer;
121+
return arrayBuffer.slice(byteOffset, byteOffset + byteLength);
122+
};
123+
124+
// imported from https://github.com/kolodziejczak-sz/uwebsocket-serve
125+
export function serveFile(res /* : HttpResponse */, filepath: string) {
126+
const { size } = statSync(filepath);
127+
const readStream = createReadStream(filepath);
128+
const destroyReadStream = () => !readStream.destroyed && readStream.destroy();
129+
130+
const onError = (error: Error) => {
131+
destroyReadStream();
132+
throw error;
133+
};
134+
135+
const onDataChunk = (chunk: Buffer) => {
136+
const arrayBufferChunk = toArrayBuffer(chunk);
137+
138+
const lastOffset = res.getWriteOffset();
139+
const [ok, done] = res.tryEnd(arrayBufferChunk, size);
140+
141+
if (!done && !ok) {
142+
readStream.pause();
143+
144+
res.onWritable((offset) => {
145+
const [ok, done] = res.tryEnd(
146+
arrayBufferChunk.slice(offset - lastOffset),
147+
size
148+
);
149+
150+
if (!done && ok) {
151+
readStream.resume();
152+
}
153+
154+
return ok;
155+
});
156+
}
157+
};
158+
159+
res.onAborted(destroyReadStream);
160+
readStream
161+
.on("data", onDataChunk)
162+
.on("error", onError)
163+
.on("end", destroyReadStream);
164+
}

package-lock.json

+12-22
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
"accepts": "~1.3.4",
4949
"base64id": "~2.0.0",
5050
"debug": "~4.3.2",
51-
"engine.io": "~6.0.0",
51+
"engine.io": "~6.1.0",
5252
"socket.io-adapter": "~2.3.2",
5353
"socket.io-parser": "~4.0.4"
5454
},
@@ -65,7 +65,8 @@
6565
"supertest": "^6.1.6",
6666
"ts-node": "^10.2.1",
6767
"tsd": "^0.17.0",
68-
"typescript": "^4.4.2"
68+
"typescript": "^4.4.2",
69+
"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.0.0"
6970
},
7071
"contributors": [
7172
{

test/socket.io.ts

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { io as ioc, Socket as ClientSocket } from "socket.io-client";
1414

1515
import "./support/util";
1616
import "./utility-methods";
17+
import "./uws";
1718

1819
type callback = (err: Error | null, success: boolean) => void;
1920

0 commit comments

Comments
 (0)