Skip to content

Commit

Permalink
feat: add an implementation based on uWebSockets.js
Browse files Browse the repository at this point in the history
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
  • Loading branch information
darrachequesne committed Nov 12, 2021
1 parent fe8730c commit c0d8c5a
Show file tree
Hide file tree
Showing 7 changed files with 431 additions and 26 deletions.
68 changes: 68 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Server as Engine,
ServerOptions as EngineOptions,
AttachOptions,
uServer,
} from "engine.io";
import { Client } from "./client";
import { EventEmitter } from "events";
Expand All @@ -27,6 +28,7 @@ import {
StrictEventEmitter,
EventNames,
} from "./typed-events";
import { patchAdapter, restoreAdapter, serveFile } from "./uws.js";

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

Expand Down Expand Up @@ -344,6 +346,69 @@ export class Server<
return this;
}

public attachApp(app /*: TemplatedApp */, opts: Partial<ServerOptions> = {}) {
// merge the options passed to the Socket.IO server
Object.assign(opts, this.opts);
// set engine.io path to `/socket.io`
opts.path = opts.path || this._path;

// initialize engine
debug("creating uWebSockets.js-based engine with opts %j", opts);
const engine = new uServer(opts);

engine.attach(app, opts);

// bind to engine events
this.bind(engine);

if (this._serveClient) {
// attach static file serving
app.get(`${this._path}/*`, (res, req) => {
if (!this.clientPathRegex.test(req.getUrl())) {
req.setYield(true);
return;
}

const filename = req
.getUrl()
.replace(this._path, "")
.replace(/\?.*$/, "")
.replace(/^\//, "");
const isMap = dotMapRegex.test(filename);
const type = isMap ? "map" : "source";

// Per the standard, ETags must be quoted:
// https://tools.ietf.org/html/rfc7232#section-2.3
const expectedEtag = '"' + clientVersion + '"';
const weakEtag = "W/" + expectedEtag;

const etag = req.getHeader("if-none-match");
if (etag) {
if (expectedEtag === etag || weakEtag === etag) {
debug("serve client %s 304", type);
res.writeStatus("304 Not Modified");
res.end();
return;
}
}

debug("serve client %s", type);

res.writeHeader("cache-control", "public, max-age=0");
res.writeHeader(
"content-type",
"application/" + (isMap ? "json" : "javascript")
);
res.writeHeader("etag", expectedEtag);

const filepath = path.join(__dirname, "../client-dist/", filename);
serveFile(res, filepath);
});
}

patchAdapter(app);
}

/**
* Initialize engine
*
Expand Down Expand Up @@ -562,6 +627,9 @@ export class Server<

this.engine.close();

// restore the Adapter prototype
restoreAdapter();

if (this.httpServer) {
this.httpServer.close(fn);
} else {
Expand Down
4 changes: 2 additions & 2 deletions lib/socket.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Packet, PacketType } from "socket.io-parser";
import url = require("url");
import debugModule from "debug";
import type { Server } from "./index";
import {
Expand Down Expand Up @@ -184,7 +183,8 @@ export class Socket<
secure: !!this.request.connection.encrypted,
issued: +new Date(),
url: this.request.url!,
query: url.parse(this.request.url!, true).query,
// @ts-ignore
query: this.request._query,
auth,
};
}
Expand Down
164 changes: 164 additions & 0 deletions lib/uws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { Adapter, Room } from "socket.io-adapter";
import type { WebSocket } from "uWebSockets.js";
import type { Socket } from "./socket.js";
import { createReadStream, statSync } from "fs";
import debugModule from "debug";

const debug = debugModule("socket.io:adapter-uws");

const SEPARATOR = "\x1f"; // see https://en.wikipedia.org/wiki/Delimiter#ASCII_delimited_text

const { addAll, del, broadcast } = Adapter.prototype;

export function patchAdapter(app /* : TemplatedApp */) {
Adapter.prototype.addAll = function (id, rooms) {
const isNew = !this.sids.has(id);
addAll.call(this, id, rooms);
const socket: Socket = this.nsp.sockets.get(id);
if (!socket) {
return;
}
if (socket.conn.transport.name === "websocket") {
subscribe(this.nsp.name, socket, isNew, rooms);
return;
}
if (isNew) {
socket.conn.on("upgrade", () => {
const rooms = this.sids.get(id);
subscribe(this.nsp.name, socket, isNew, rooms);
});
}
};

Adapter.prototype.del = function (id, room) {
del.call(this, id, room);
const socket: Socket = this.nsp.sockets.get(id);
if (socket && socket.conn.transport.name === "websocket") {
// @ts-ignore
const sessionId = socket.conn.id;
// @ts-ignore
const websocket: WebSocket = socket.conn.transport.socket;
const topic = `${this.nsp.name}${SEPARATOR}${room}`;
debug("unsubscribe connection %s from topic %s", sessionId, topic);
websocket.unsubscribe(topic);
}
};

Adapter.prototype.broadcast = function (packet, opts) {
const useFastPublish = opts.rooms.size <= 1 && opts.except!.size === 0;
if (!useFastPublish) {
broadcast.call(this, packet, opts);
return;
}

const flags = opts.flags || {};
const basePacketOpts = {
preEncoded: true,
volatile: flags.volatile,
compress: flags.compress,
};

packet.nsp = this.nsp.name;
const encodedPackets = this.encoder.encode(packet);

const topic =
opts.rooms.size === 0
? this.nsp.name
: `${this.nsp.name}${SEPARATOR}${opts.rooms.keys().next().value}`;
debug("fast publish to %s", topic);

// fast publish for clients connected with WebSocket
encodedPackets.forEach((encodedPacket) => {
const isBinary = typeof encodedPacket !== "string";
// "4" being the message type in the Engine.IO protocol, see https://github.com/socketio/engine.io-protocol
app.publish(
topic,
isBinary ? encodedPacket : "4" + encodedPacket,
isBinary
);
});

this.apply(opts, (socket) => {
if (socket.conn.transport.name !== "websocket") {
// classic publish for clients connected with HTTP long-polling
for (let i = 0; i < encodedPackets.length; i++) {
socket.client.writeToEngine(encodedPackets[i], basePacketOpts);
}
}
});
};
}

function subscribe(
namespaceName: string,
socket: Socket,
isNew: boolean,
rooms: Set<Room>
) {
// @ts-ignore
const sessionId = socket.conn.id;
// @ts-ignore
const websocket: WebSocket = socket.conn.transport.socket;
if (isNew) {
debug("subscribe connection %s to topic %s", sessionId, namespaceName);
websocket.subscribe(namespaceName);
}
rooms.forEach((room) => {
const topic = `${namespaceName}${SEPARATOR}${room}`; // '#' can be used as wildcard
debug("subscribe connection %s to topic %s", sessionId, topic);
websocket.subscribe(topic);
});
}

export function restoreAdapter() {
Adapter.prototype.addAll = addAll;
Adapter.prototype.del = del;
Adapter.prototype.broadcast = broadcast;
}

const toArrayBuffer = (buffer: Buffer) => {
const { buffer: arrayBuffer, byteOffset, byteLength } = buffer;
return arrayBuffer.slice(byteOffset, byteOffset + byteLength);
};

// imported from https://github.com/kolodziejczak-sz/uwebsocket-serve
export function serveFile(res /* : HttpResponse */, filepath: string) {
const { size } = statSync(filepath);
const readStream = createReadStream(filepath);
const destroyReadStream = () => !readStream.destroyed && readStream.destroy();

const onError = (error: Error) => {
destroyReadStream();
throw error;
};

const onDataChunk = (chunk: Buffer) => {
const arrayBufferChunk = toArrayBuffer(chunk);

const lastOffset = res.getWriteOffset();
const [ok, done] = res.tryEnd(arrayBufferChunk, size);

if (!done && !ok) {
readStream.pause();

res.onWritable((offset) => {
const [ok, done] = res.tryEnd(
arrayBufferChunk.slice(offset - lastOffset),
size
);

if (!done && ok) {
readStream.resume();
}

return ok;
});
}
};

res.onAborted(destroyReadStream);
readStream
.on("data", onDataChunk)
.on("error", onError)
.on("end", destroyReadStream);
}
34 changes: 12 additions & 22 deletions package-lock.json

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

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"debug": "~4.3.2",
"engine.io": "~6.0.0",
"engine.io": "~6.1.0",
"socket.io-adapter": "~2.3.2",
"socket.io-parser": "~4.0.4"
},
Expand All @@ -65,7 +65,8 @@
"supertest": "^6.1.6",
"ts-node": "^10.2.1",
"tsd": "^0.17.0",
"typescript": "^4.4.2"
"typescript": "^4.4.2",
"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.0.0"
},
"contributors": [
{
Expand Down
1 change: 1 addition & 0 deletions test/socket.io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { io as ioc, Socket as ClientSocket } from "socket.io-client";

import "./support/util";
import "./utility-methods";
import "./uws";

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

Expand Down
Loading

0 comments on commit c0d8c5a

Please sign in to comment.