Compare commits

..

45 Commits
4.5.4 ... 4.7.1

Author SHA1 Message Date
Damien Arrachequesne
2f6cc2fa42 chore(release): 4.7.1
Diff: https://github.com/socketio/socket.io/compare/4.7.0...4.7.1
2023-06-28 09:32:32 +02:00
Damien Arrachequesne
00d8ee5b05 chore(release): 4.7.0
Diff: https://github.com/socketio/socket.io/compare/4.6.2...4.7.0
2023-06-22 11:27:45 +02:00
Damien Arrachequesne
2dd5fa9dd4 ci: add Node.js 20 in the test matrix
Reference: https://github.com/nodejs/Release
2023-06-22 07:55:32 +02:00
Damien Arrachequesne
a5dff0ac83 docs(examples): increase httpd ProxyTimeout value (2) 2023-06-21 00:07:44 +02:00
Damien Arrachequesne
3035c25982 docs(examples): increase httpd ProxyTimeout value
With a value that is too small, the HTTP long-polling request receives
an HTTP 502 response code and the connection gets closed.
2023-06-20 23:57:46 +02:00
Damien Arrachequesne
63f181cc12 feat: serve client bundles with CORS headers
The version of the `cors` package matches the one used by `engine.io`.

Related: https://github.com/socketio/socket.io/issues/3552
2023-06-20 14:32:59 +02:00
Damien Arrachequesne
a250e283da chore: bump engine.io to version 6.5.0
Diff: https://github.com/socketio/engine.io/compare/6.4.2...6.5.0
Release notes: https://github.com/socketio/engine.io/releases/tag/6.5.0
2023-06-20 09:17:10 +02:00
SyedTayyabUlMazhar
e5c62cad60 fix: remove the Partial modifier from the socket.data type (#4740)
Wrapping SocketData with Partial causes issues when reading data even
if you've made sure to pass all values. If someone want to make their
type Partial or make only a few properties optional, they can do so in
their own type instead.

Related: https://github.com/socketio/socket.io/issues/4537
2023-06-20 07:49:02 +02:00
Damien Arrachequesne
01d37624a8 docs(changelog): update the version range of the engine.io dependency 2023-05-31 11:28:00 +02:00
Damien Arrachequesne
faf914c9ab chore(release): 4.6.2
Diff: https://github.com/socketio/socket.io/compare/4.6.1...4.6.2
2023-05-31 11:15:41 +02:00
Damien Arrachequesne
15af22fc22 refactor: add a noop handler for the error event
We should reduce the scope of the "event" error in the next major
version, as it is overloaded today:

- it can be sent by the client (`socket.emit("error")`, which is a perfectly valid event name)
- it can be emitted when the connection encounters an error (an invalid packet for example)
- it can be emitted when a packet is rejected in a middleware (`socket.use()`)

Related: https://github.com/socketio/socket.io/issues/2047
2023-05-24 10:47:52 +02:00
Damien Arrachequesne
d3658944e5 chore: bump socket.io-parser to version 4.2.3
Reference: https://github.com/advisories/GHSA-cqmj-92xf-r6r9
2023-05-24 07:27:12 +02:00
Damien Arrachequesne
12b0de4f52 chore: bump engine.io to version 6.4.2
Reference: https://github.com/advisories/GHSA-q9mw-68c2-j6m5

Related: https://github.com/socketio/socket.io/issues/4711
2023-05-10 10:20:42 +02:00
Mateusz Burzyński
3d44aae381 fix(exports): move types condition to the top (#4698)
Related: https://github.com/microsoft/TypeScript/issues/50762
2023-05-04 07:27:09 +02:00
Damien Arrachequesne
cbf0362476 docs(examples): bump dependencies for the private messaging example
Related: https://github.com/socketio/socket.io/issues/4681
2023-05-02 18:07:07 +02:00
Damien Arrachequesne
59280da20b docs(examples): update examples to docker compose v2
Reference: https://docs.docker.com/compose/

Related: https://github.com/socketio/socket.io/discussions/4669
2023-04-07 15:57:20 +02:00
Damien Arrachequesne
50a4d37cb8 docs(changelog): add version of transitive dependencies 2023-03-27 17:35:42 +02:00
Damien Arrachequesne
6458b2bef1 docs(example): basic WebSocket-only client 2023-03-24 11:17:29 +01:00
Damien Arrachequesne
b56da8a99f docs(examples): upgrade to React 18
Reference: https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html
2023-03-07 08:31:21 +01:00
Damien Arrachequesne
7952312911 chore(release): 4.6.1
Diff: https://github.com/socketio/socket.io/compare/4.6.0...4.6.1
2023-02-20 17:49:41 +01:00
Damien Arrachequesne
0d0a7a22b5 fix: properly handle manually created dynamic namespaces
Namespaces that match the regex of a parent namespace will now be added
as a child of this namespace:

```js
const parentNamespace = io.of(/^\/dynamic-\d+$/);
const childNamespace = io.of("/dynamic-101");
```

Related:

- https://github.com/socketio/socket.io/issues/4615
- https://github.com/socketio/socket.io/issues/4164
- https://github.com/socketio/socket.io/issues/4015
- https://github.com/socketio/socket.io/issues/3960
2023-02-20 01:19:01 +01:00
Damien Arrachequesne
2a8565fd1e refactor: catch errors when trying to restore the connection state 2023-02-20 01:18:08 +01:00
Igor Lins e Silva
d0b22c6302 fix(types): fix nodenext module resolution compatibility (#4625)
The import added in [1] was invalid, because it used an non-exported
class.

Related: https://github.com/socketio/socket.io/issues/4621

[1]: d4a9b2cdcb
2023-02-20 01:15:35 +01:00
Nabaraj Subedi
e71f3d7dbe docs: minor style fix (#4619) 2023-02-16 09:25:43 +01:00
Damien Arrachequesne
a2e5d1f77f chore(release): 4.6.0
Diff: https://github.com/socketio/socket.io/compare/4.5.4...4.6.0
2023-02-07 01:07:46 +01:00
Damien Arrachequesne
d8143cc067 refactor: do not persist session if connection state recovery if disabled
This is a follow-up commit of [1]. Without it, adapter.persistSession()
would be called even if the connection state recovery feature was
disabled.

[1]: 54d5ee05a6
2023-02-06 18:03:34 +01:00
Damien Arrachequesne
b2dd7cf660 chore: bump engine.io to version 6.4.0
Diff: https://github.com/socketio/engine.io/compare/6.3.1...6.4.0
Release notes: https://github.com/socketio/engine.io/releases/tag/6.4.0
2023-02-06 17:43:02 +01:00
Damien Arrachequesne
3734b74b45 revert: feat: expose current offset to allow deduplication
This reverts commit 4e64123862.

Using the id of the socket is not possible, since it is lost upon
reconnection (unless connection recovery is successful), so we revert
the previous change.
2023-02-06 17:36:37 +01:00
Edouard Benauw
8aa94991ce feat: add description to the disconnecting and disconnect events (#4622)
See also: b862924b7f
2023-02-04 09:03:01 +01:00
Damien Arrachequesne
4e64123862 feat: expose current offset to allow deduplication
Related: 655dce9755
2023-02-04 08:56:55 +01:00
Damien Arrachequesne
115a9819fd refactor: do not include the pid by default
So that the client knows whether the connection state recovery feature
is enabled.

See also: 54d5ee05a6
2023-01-25 09:39:22 +01:00
Waldemar Schlegel
0c0eb00163 fix: add timeout method to remote socket (#4558)
The RemoteSocket interface, which is returned when the client is
connected on another Socket.IO server of the cluster, was lacking the
`timeout()` method.

Syntax:

```js
const sockets = await io.fetchSockets();

for (const socket of sockets) {
  if (someCondition) {
    socket.timeout(1000).emit("some-event", (err) => {
      if (err) {
        // the client did not acknowledge the event in the given delay
      }
    });
  }
}
```

Related: https://github.com/socketio/socket.io/issues/4595
2023-01-24 09:24:19 +01:00
Damien Arrachequesne
f8640d9451 refactor: export DisconnectReason type
Related: https://github.com/socketio/socket.io/issues/4556
2023-01-23 09:27:32 +01:00
Damien Arrachequesne
93d446a545 refactor: add charset when serving the bundle files
Reference: https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/Content-Type

Related: https://github.com/socketio/socket.io/discussions/4589
2023-01-23 09:27:06 +01:00
Damien Arrachequesne
184f3cf7af feat: add promise-based acknowledgements
This commit adds some syntactic sugar around acknowledgements:

- `emitWithAck()`

```js
try {
  const responses = await io.timeout(1000).emitWithAck("some-event");
  console.log(responses); // one response per client
} catch (e) {
  // some clients did not acknowledge the event in the given delay
}

io.on("connection", async (socket) => {
    // without timeout
  const response = await socket.emitWithAck("hello", "world");

  // with a specific timeout
  try {
    const response = await socket.timeout(1000).emitWithAck("hello", "world");
  } catch (err) {
    // the client did not acknowledge the event in the given delay
  }
});
```

- `serverSideEmitWithAck()`

```js
try {
  const responses = await io.timeout(1000).serverSideEmitWithAck("some-event");
  console.log(responses); // one response per server (except itself)
} catch (e) {
  // some servers did not acknowledge the event in the given delay
}
```

Related:

- https://github.com/socketio/socket.io/issues/4175
- https://github.com/socketio/socket.io/issues/4577
- https://github.com/socketio/socket.io/issues/4583
2023-01-23 09:06:25 +01:00
Steve Baum
5d9220b69a feat: add the ability to clean up empty child namespaces (#4602)
This commit adds a new option, "cleanupEmptyChildNamespaces". With this
option enabled (disabled by default), when a socket disconnects from a
dynamic namespace and if there are no other sockets connected to it
then the namespace will be cleaned up and its adapter will be closed.

Note: the namespace can be connected to later (it will be recreated)

Related: https://github.com/socketio/socket.io-redis-adapter/issues/480
2023-01-23 07:56:14 +01:00
Damien Arrachequesne
129883958a test: add test with onAnyOutgoing() and binary attachments
Related:

- https://github.com/socketio/socket.io/issues/4374
- ae8dd88995
2023-01-19 12:05:35 +01:00
Damien Arrachequesne
6c27b8b0a6 test: add test with socket.disconnect(true)
Related: a65a047526
2023-01-19 11:53:53 +01:00
Damien Arrachequesne
f3ada7d8cc fix(typings): properly type emits with timeout
When emitting with a timeout (added in version 4.4.0), the "err"
argument was not properly typed and would require to split the client
and server typings. It will now be automatically inferred as an Error
object.

Workaround for previous versions:

```ts
type WithTimeoutAck<isEmitter extends boolean, args extends any[]> = isEmitter extends true ? [Error, ...args] : args;

interface ClientToServerEvents<isEmitter extends boolean = false> {
    withAck: (data: { argName: boolean }, callback: (...args: WithTimeoutAck<isEmitter, [string]>) => void) => void;
}

interface ServerToClientEvents<isEmitter extends boolean = false> {

}

const io = new Server<ClientToServerEvents, ServerToClientEvents<true>>(3000);

io.on("connection", (socket) => {
    socket.on("withAck", (val, cb) => {
        cb("123");
    });
});

const socket: Socket<ServerToClientEvents, ClientToServerEvents<true>> = ioc("http://localhost:3000");

socket.timeout(100).emit("withAck", { argName: true }, (err, val) => {
  // ...
});
```

Related: https://github.com/socketio/socket.io-client/issues/1555
2023-01-19 11:48:18 +01:00
Marc Jansing
a21ad88828 docs(changelog): add note about maxHttpBufferSize default value (#4596)
Reference: https://github.com/socketio/socket.io/releases/tag/2.5.0
2023-01-18 08:16:27 +01:00
Damien Arrachequesne
54d5ee05a6 feat: implement connection state recovery
Connection state recovery allows a client to reconnect after a
temporary disconnection and restore its state:

- id
- rooms
- data
- missed packets

Usage:

```js
import { Server } from "socket.io";

const io = new Server({
  connectionStateRecovery: {
    // default values
    maxDisconnectionDuration: 2 * 60 * 1000,
    skipMiddlewares: true,
  },
});

io.on("connection", (socket) => {
  console.log(socket.recovered); // whether the state was recovered or not
});
```

Here's how it works:

- the server sends a session ID during the handshake (which is
different from the current `id` attribute, which is public and can be
freely shared)

- the server also includes an offset in each packet (added at the end
of the data array, for backward compatibility)

- upon temporary disconnection, the server stores the client state for
a given delay (implemented at the adapter level)

- upon reconnection, the client sends both the session ID and the last
offset it has processed, and the server tries to restore the state

A few notes:

- the base adapter exposes two additional methods, persistSession() and
restoreSession(), that must be implemented by the other adapters in
order to allow the feature to work within a cluster

See: f5294126a8

- acknowledgements are not affected, because it won't work if the
client reconnects on another server (as the ack id is local)

- any disconnection that lasts longer than the
`maxDisconnectionDuration` value will result in a new session, so users
will still need to care for the state reconciliation between the server
and the client

Related: https://github.com/socketio/socket.io/discussions/4510
2023-01-12 12:21:56 +01:00
Damien Arrachequesne
da2b542797 perf: precompute the WebSocket frames when broadcasting
Note:

- only packets without binary attachments are affected
- the permessage-deflate extension must be disabled (which is the default)

Related:

- 5f7b47d40f
- 5e34722b0b
2023-01-12 08:50:07 +01:00
Tristan F
b7d54dbe8d docs: add Rust client implementation (#4592)
client-only implementation -- it *may* add server-side support in the future.
2023-01-12 06:26:22 +01:00
Tristan F
d4a9b2cdcb refactor(typings): add types for io.engine (#4591)
This adds typings for the socket.io engine field, which offers better
IntelliSense when retrieving the server, as well as more confidence on
the developer-side of what types of fields are entering the server.

Related: https://github.com/socketio/socket.io/issues/4590
2023-01-11 10:45:57 +01:00
Damien Arrachequesne
547c541fb9 chore: add security policy 2022-12-14 07:47:51 +01:00
52 changed files with 9620 additions and 9705 deletions

View File

@@ -16,7 +16,9 @@ jobs:
strategy:
matrix:
node-version: [12, 14, 16]
node-version:
- 16
- 20
steps:
- name: Checkout repository

View File

@@ -1,5 +1,13 @@
# History
## 2023
- [4.7.1](#471-2023-06-28) (Jun 2023)
- [4.7.0](#470-2023-06-22) (Jun 2023)
- [4.6.2](#462-2023-05-31) (May 2023)
- [4.6.1](#461-2023-02-20) (Feb 2023)
- [4.6.0](#460-2023-02-07) (Feb 2023)
## 2022
- [4.5.4](#454-2022-11-22) (Nov 2022)
@@ -52,6 +60,315 @@
# Release notes
## [4.7.1](https://github.com/socketio/socket.io/compare/4.7.0...4.7.1) (2023-06-28)
The client bundle contains a few fixes regarding the WebTransport support.
### Dependencies
- [`engine.io@~6.5.0`](https://github.com/socketio/engine.io/releases/tag/6.5.0) (no change)
- [`ws@~8.11.0`](https://github.com/websockets/ws/releases/tag/8.11.0) (no change)
## [4.7.0](https://github.com/socketio/socket.io/compare/4.6.2...4.7.0) (2023-06-22)
### Bug Fixes
* remove the Partial modifier from the socket.data type ([#4740](https://github.com/socketio/socket.io/issues/4740)) ([e5c62ca](https://github.com/socketio/socket.io/commit/e5c62cad60fc7d16fbb024fd9be1d1880f4e6f5f))
### Features
#### Support for WebTransport
The Socket.IO server can now use WebTransport as the underlying transport.
WebTransport is a web API that uses the HTTP/3 protocol as a bidirectional transport. It's intended for two-way communications between a web client and an HTTP/3 server.
References:
- https://w3c.github.io/webtransport/
- https://developer.mozilla.org/en-US/docs/Web/API/WebTransport
- https://developer.chrome.com/articles/webtransport/
Until WebTransport support lands [in Node.js](https://github.com/nodejs/node/issues/38478), you can use the `@fails-components/webtransport` package:
```js
import { readFileSync } from "fs";
import { createServer } from "https";
import { Server } from "socket.io";
import { Http3Server } from "@fails-components/webtransport";
// WARNING: the total length of the validity period MUST NOT exceed two weeks (https://w3c.github.io/webtransport/#custom-certificate-requirements)
const cert = readFileSync("/path/to/my/cert.pem");
const key = readFileSync("/path/to/my/key.pem");
const httpsServer = createServer({
key,
cert
});
httpsServer.listen(3000);
const io = new Server(httpsServer, {
transports: ["polling", "websocket", "webtransport"] // WebTransport is not enabled by default
});
const h3Server = new Http3Server({
port: 3000,
host: "0.0.0.0",
secret: "changeit",
cert,
privKey: key,
});
(async () => {
const stream = await h3Server.sessionStream("/socket.io/");
const sessionReader = stream.getReader();
while (true) {
const { done, value } = await sessionReader.read();
if (done) {
break;
}
io.engine.onWebTransportSession(value);
}
})();
h3Server.startServer();
```
Added in [123b68c](https://github.com/socketio/engine.io/commit/123b68c04f9e971f59b526e0f967a488ee6b0116).
#### Client bundles with CORS headers
The bundles will now have the right `Access-Control-Allow-xxx` headers.
Added in [63f181c](https://github.com/socketio/socket.io/commit/63f181cc12cbbbf94ed40eef52d60f36a1214fbe).
### Dependencies
- [`engine.io@~6.5.0`](https://github.com/socketio/engine.io/releases/tag/6.5.0) ([diff](https://github.com/socketio/engine.io/compare/6.4.2...6.5.0))
- [`ws@~8.11.0`](https://github.com/websockets/ws/releases/tag/8.11.0) (no change)
## [4.6.2](https://github.com/socketio/socket.io/compare/4.6.1...4.6.2) (2023-05-31)
### Bug Fixes
* **exports:** move `types` condition to the top ([#4698](https://github.com/socketio/socket.io/issues/4698)) ([3d44aae](https://github.com/socketio/socket.io/commit/3d44aae381af38349fdb808d510d9f47a0c2507e))
### Dependencies
- [`engine.io@~6.4.2`](https://github.com/socketio/engine.io/releases/tag/6.4.0) ([diff](https://github.com/socketio/engine.io/compare/6.4.1...6.4.2))
- [`ws@~8.11.0`](https://github.com/websockets/ws/releases/tag/8.11.0) (no change)
## [4.6.1](https://github.com/socketio/socket.io/compare/4.6.0...4.6.1) (2023-02-20)
### Bug Fixes
* properly handle manually created dynamic namespaces ([0d0a7a2](https://github.com/socketio/socket.io/commit/0d0a7a22b5ff95f864216c529114b7dd41738d1e))
* **types:** fix nodenext module resolution compatibility ([#4625](https://github.com/socketio/socket.io/issues/4625)) ([d0b22c6](https://github.com/socketio/socket.io/commit/d0b22c630208669aceb7ae013180c99ef90279b0))
### Dependencies
- [`engine.io@~6.4.1`](https://github.com/socketio/engine.io/releases/tag/6.4.1) ([diff](https://github.com/socketio/engine.io/compare/6.4.0...6.4.1))
- [`ws@~8.11.0`](https://github.com/websockets/ws/releases/tag/8.11.0) (no change)
## [4.6.0](https://github.com/socketio/socket.io/compare/4.5.4...4.6.0) (2023-02-07)
### Bug Fixes
* add timeout method to remote socket ([#4558](https://github.com/socketio/socket.io/issues/4558)) ([0c0eb00](https://github.com/socketio/socket.io/commit/0c0eb0016317218c2be3641e706cfaa9bea39a2d))
* **typings:** properly type emits with timeout ([f3ada7d](https://github.com/socketio/socket.io/commit/f3ada7d8ccc02eeced2b9b9ac8e4bc921eb630d2))
### Features
#### Promise-based acknowledgements
This commit adds some syntactic sugar around acknowledgements:
- `emitWithAck()`
```js
try {
const responses = await io.timeout(1000).emitWithAck("some-event");
console.log(responses); // one response per client
} catch (e) {
// some clients did not acknowledge the event in the given delay
}
io.on("connection", async (socket) => {
// without timeout
const response = await socket.emitWithAck("hello", "world");
// with a specific timeout
try {
const response = await socket.timeout(1000).emitWithAck("hello", "world");
} catch (err) {
// the client did not acknowledge the event in the given delay
}
});
```
- `serverSideEmitWithAck()`
```js
try {
const responses = await io.timeout(1000).serverSideEmitWithAck("some-event");
console.log(responses); // one response per server (except itself)
} catch (e) {
// some servers did not acknowledge the event in the given delay
}
```
Added in [184f3cf](https://github.com/socketio/socket.io/commit/184f3cf7af57acc4b0948eee307f25f8536eb6c8).
#### Connection state recovery
This feature allows a client to reconnect after a temporary disconnection and restore its state:
- id
- rooms
- data
- missed packets
Usage:
```js
import { Server } from "socket.io";
const io = new Server({
connectionStateRecovery: {
// default values
maxDisconnectionDuration: 2 * 60 * 1000,
skipMiddlewares: true,
},
});
io.on("connection", (socket) => {
console.log(socket.recovered); // whether the state was recovered or not
});
```
Here's how it works:
- the server sends a session ID during the handshake (which is different from the current `id` attribute, which is public and can be freely shared)
- the server also includes an offset in each packet (added at the end of the data array, for backward compatibility)
- upon temporary disconnection, the server stores the client state for a given delay (implemented at the adapter level)
- upon reconnection, the client sends both the session ID and the last offset it has processed, and the server tries to restore the state
The in-memory adapter already supports this feature, and we will soon update the Postgres and MongoDB adapters. We will also create a new adapter based on [Redis Streams](https://redis.io/docs/data-types/streams/), which will support this feature.
Added in [54d5ee0](https://github.com/socketio/socket.io/commit/54d5ee05a684371191e207b8089f09fc24eb5107).
#### Compatibility (for real) with Express middlewares
This feature implements middlewares at the Engine.IO level, because Socket.IO middlewares are meant for namespace authorization and are not executed during a classic HTTP request/response cycle.
Syntax:
```js
io.engine.use((req, res, next) => {
// do something
next();
});
// with express-session
import session from "express-session";
io.engine.use(session({
secret: "keyboard cat",
resave: false,
saveUninitialized: true,
cookie: { secure: true }
}));
// with helmet
import helmet from "helmet";
io.engine.use(helmet());
```
A workaround was possible by using the allowRequest option and the "headers" event, but this feels way cleaner and works with upgrade requests too.
Added in [24786e7](https://github.com/socketio/engine.io/commit/24786e77c5403b1c4b5a2bc84e2af06f9187f74a).
#### Error details in the disconnecting and disconnect events
The `disconnect` event will now contain additional details about the disconnection reason.
```js
io.on("connection", (socket) => {
socket.on("disconnect", (reason, description) => {
console.log(description);
});
});
```
Added in [8aa9499](https://github.com/socketio/socket.io/commit/8aa94991cee5518567d6254eec04b23f81510257).
#### Automatic removal of empty child namespaces
This commit adds a new option, "cleanupEmptyChildNamespaces". With this option enabled (disabled by default), when a socket disconnects from a dynamic namespace and if there are no other sockets connected to it then the namespace will be cleaned up and its adapter will be closed.
```js
import { createServer } from "node:http";
import { Server } from "socket.io";
const httpServer = createServer();
const io = new Server(httpServer, {
cleanupEmptyChildNamespaces: true
});
```
Added in [5d9220b](https://github.com/socketio/socket.io/commit/5d9220b69adf73e086c27bbb63a4976b348f7c4c).
#### A new "addTrailingSlash" option
The trailing slash which was added by default can now be disabled:
```js
import { createServer } from "node:http";
import { Server } from "socket.io";
const httpServer = createServer();
const io = new Server(httpServer, {
addTrailingSlash: false
});
```
In the example above, the clients can omit the trailing slash and use `/socket.io` instead of `/socket.io/`.
Added in [d0fd474](https://github.com/socketio/engine.io/commit/d0fd4746afa396297f07bb62e539b0c1c4018d7c).
### Performance Improvements
* precompute the WebSocket frames when broadcasting ([da2b542](https://github.com/socketio/socket.io/commit/da2b54279749adc5279c9ac4742b01b36c01cff0))
### Dependencies
- [`engine.io@~6.4.0`](https://github.com/socketio/engine.io/releases/tag/6.4.0) (https://github.com/socketio/engine.io/compare/6.2.1...6.4.0)
- [`ws@~8.11.0`](https://github.com/websockets/ws/releases/tag/8.11.0) (https://github.com/websockets/ws/compare/8.2.3...8.11.0)
## [4.5.4](https://github.com/socketio/socket.io/compare/4.5.3...4.5.4) (2022-11-22)
This release contains a bump of:
@@ -61,8 +378,8 @@ This release contains a bump of:
### Dependencies
- [`engine.io@~6.2.1`](https://github.com/socketio/engine.io-client/tree/6.2.1) ([diff](https://github.com/socketio/engine.io/compare/6.2.0...6.2.1))
- [`ws@~8.2.3`](https://github.com/websockets/ws/releases/tag/8.2.3)
- [`engine.io@~6.2.1`](https://github.com/socketio/engine.io/releases/tag/6.2.1) ([diff](https://github.com/socketio/engine.io/compare/6.2.0...6.2.1))
- [`ws@~8.2.3`](https://github.com/websockets/ws/releases/tag/8.2.3) (no change)
@@ -74,6 +391,11 @@ This release contains a bump of:
* **typings:** accept an HTTP2 server in the constructor ([d3d0a2d](https://github.com/socketio/socket.io/commit/d3d0a2d5beaff51fd145f810bcaf6914213f8a06))
* **typings:** apply types to "io.timeout(...).emit()" calls ([e357daf](https://github.com/socketio/socket.io/commit/e357daf5858560bc84e7e50cd36f0278d6721ea1))
### Dependencies
- [`engine.io@~6.2.0`](https://github.com/socketio/engine.io/releases/tag/6.2.1) (no change)
- [`ws@~8.2.3`](https://github.com/websockets/ws/releases/tag/8.2.3) (no change)
## [4.5.2](https://github.com/socketio/socket.io/compare/4.5.1...4.5.2) (2022-09-02)
@@ -84,10 +406,21 @@ This release contains a bump of:
* prevent the socket from joining a room after disconnection ([18f3fda](https://github.com/socketio/socket.io/commit/18f3fdab12947a9fee3e9c37cfc1da97027d1473))
* **uws:** prevent the server from crashing after upgrade ([ba497ee](https://github.com/socketio/socket.io/commit/ba497ee3eb52c4abf1464380d015d8c788714364))
### Dependencies
- [`engine.io@~6.2.0`](https://github.com/socketio/engine.io/releases/tag/6.2.0) (no change)
- [`ws@~8.2.3`](https://github.com/websockets/ws/releases/tag/8.2.3) (no change)
# [2.5.0](https://github.com/socketio/socket.io/compare/2.4.1...2.5.0) (2022-06-26)
⚠️ WARNING ⚠️
The default value of the `maxHttpBufferSize` option has been decreased from 100 MB to 1 MB, in order to prevent attacks by denial of service.
Security advisory: [GHSA-j4f2-536g-r55m](https://github.com/advisories/GHSA-j4f2-536g-r55m)
### Bug Fixes
@@ -96,6 +429,11 @@ This release contains a bump of:
* only set 'connected' to true after middleware execution ([226cc16](https://github.com/socketio/socket.io/commit/226cc16165f9fe60f16ff4d295fb91c8971cde35))
* prevent the socket from joining a room after disconnection ([f223178](https://github.com/socketio/socket.io/commit/f223178eb655a7713303b21a78f9ef9e161d6458))
### Dependencies
- [`engine.io@~3.6.0`](https://github.com/socketio/engine.io/releases/tag/3.6.0) (https://github.com/socketio/engine.io/compare/3.5.0...3.6.0)
- [`ws@~7.4.2`](https://github.com/websockets/ws/releases/tag/7.4.2) (no change)
## [4.5.1](https://github.com/socketio/socket.io/compare/4.5.0...4.5.1) (2022-05-17)
@@ -106,6 +444,11 @@ This release contains a bump of:
* forward the local flag to the adapter when using fetchSockets() ([30430f0](https://github.com/socketio/socket.io/commit/30430f0985f8e7c49394543d4c84913b6a15df60))
* **typings:** add HTTPS server to accepted types ([#4351](https://github.com/socketio/socket.io/issues/4351)) ([9b43c91](https://github.com/socketio/socket.io/commit/9b43c9167cff817c60fa29dbda2ef7cd938aff51))
### Dependencies
- [`engine.io@~6.2.0`](https://github.com/socketio/engine.io/releases/tag/6.2.0) (no change)
- [`ws@~8.2.3`](https://github.com/websockets/ws/releases/tag/8.2.3) (no change)
# [4.5.0](https://github.com/socketio/socket.io/compare/4.4.1...4.5.0) (2022-04-23)
@@ -118,7 +461,7 @@ This release contains a bump of:
### Features
* add support for catch-all listeners for outgoing packets ([531104d](https://github.com/socketio/socket.io/commit/531104d332690138b7aab84d5583d6204132c8b4))
#### Catch-all listeners for outgoing packets
This is similar to `onAny()`, but for outgoing packets.
@@ -130,7 +473,9 @@ socket.onAnyOutgoing((event, ...args) => {
});
```
* broadcast and expect multiple acks ([8b20457](https://github.com/socketio/socket.io/commit/8b204570a94979bbec307f23ca078f30f5cf07b0))
Added in [531104d](https://github.com/socketio/socket.io/commit/531104d332690138b7aab84d5583d6204132c8b4).
#### Broadcast and expect multiple acknowledgements
Syntax:
@@ -140,18 +485,25 @@ io.timeout(1000).emit("some-event", (err, responses) => {
});
```
* add the "maxPayload" field in the handshake details ([088dcb4](https://github.com/socketio/engine.io/commit/088dcb4dff60df39785df13d0a33d3ceaa1dff38))
Added in [8b20457](https://github.com/socketio/socket.io/commit/8b204570a94979bbec307f23ca078f30f5cf07b0).
So that clients in HTTP long-polling can decide how many packets they have to send to stay under the maxHttpBufferSize
value.
#### `maxHttpBufferSize` value negotiation
This is a backward compatible change which should not mandate a new major revision of the protocol (we stay in v4), as
we only add a field in the JSON-encoded handshake data:
A "maxPayload" field is now included in the Engine.IO handshake, so that clients in HTTP long-polling can decide how many packets they have to send to stay under the `maxHttpBufferSize` value.
This is a backward compatible change which should not mandate a new major revision of the protocol (we stay in v4), as we only add a field in the JSON-encoded handshake data:
```
0{"sid":"lv_VI97HAXpY6yYWAAAC","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":5000,"maxPayload":1000000}
```
Added in [088dcb4](https://github.com/socketio/engine.io/commit/088dcb4dff60df39785df13d0a33d3ceaa1dff38).
### Dependencies
- [`engine.io@~6.2.0`](https://github.com/socketio/engine.io/releases/tag/6.2.0) (https://github.com/socketio/engine.io/compare/6.1.0...6.2.0)
- [`ws@~8.2.3`](https://github.com/websockets/ws/releases/tag/8.2.3) (no change)
## [4.4.1](https://github.com/socketio/socket.io/compare/4.4.0...4.4.1) (2022-01-06)

View File

@@ -21,6 +21,7 @@ Some implementations in other languages are also available:
- [Dart](https://github.com/rikulo/socket.io-client-dart)
- [Python](https://github.com/miguelgrinberg/python-socketio)
- [.NET](https://github.com/doghappy/socket.io-client-csharp)
- [Rust](https://github.com/1c3t3a/rust-socketio)
Its main features are:
@@ -125,7 +126,7 @@ io.listen(3000);
Starting with **3.0**, express applications have become request handler
functions that you pass to `http` or `http` `Server` instances. You need
to pass the `Server` to `socket.io`, and not the express application
to pass the `Server` to `socket.io`, not the express application
function. Also make sure to call `.listen` on the `server`, not the `app`.
```js

22
SECURITY.md Normal file
View File

@@ -0,0 +1,22 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|--------------------|
| 4.x | :white_check_mark: |
| 3.x | :white_check_mark: |
| 2.4.x | :white_check_mark: |
| < 2.4.0 | :x: |
## Reporting a Vulnerability
To report a security vulnerability in this package, please send an email to [@darrachequesne](https://github.com/darrachequesne) (see address in profile) describing the vulnerability and how to reproduce it.
We will get back to you as soon as possible and publish a fix if necessary.
:warning: IMPORTANT :warning: please do not create an issue in this repository, as attackers might take advantage of it. Thank you in advance for your responsible disclosure.
## History
No security vulnerability were reported yet.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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
```

View 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};

View 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`);

View 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"
}
}

View 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()],
}
};

View 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);
}

View 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");
});
});

View File

@@ -1,51 +1,53 @@
services:
haproxy:
image: haproxy:1.7-alpine
volumes:
- ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
links:
- server-john
- server-paul
- server-george
- server-ringo
ports:
- "3000:80"
haproxy:
build: ./haproxy
links:
- server-john
- server-paul
- server-george
- server-ringo
ports:
- "3000:80"
server-john:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=John
server-john:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=John
server-paul:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=Paul
server-paul:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=Paul
server-george:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=George
server-george:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=George
server-ringo:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=Ringo
server-ringo:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=Ringo
redis:
image: redis:alpine
expose:
- "6379"
redis:
image: redis:alpine
expose:
- "6379"

View File

@@ -1,2 +0,0 @@
FROM haproxy:1.7-alpine
COPY haproxy.cfg /usr/local/etc/haproxy/haproxy.cfg

View File

@@ -1,51 +1,53 @@
services:
httpd:
image: httpd:2.4-alpine
volumes:
- ./httpd.conf:/usr/local/apache2/conf/httpd.conf:ro
links:
- server-john
- server-paul
- server-george
- server-ringo
ports:
- "3000:80"
httpd:
build: ./httpd
links:
- server-john
- server-paul
- server-george
- server-ringo
ports:
- "3000:80"
server-john:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=John
server-john:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=John
server-paul:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=Paul
server-paul:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=Paul
server-george:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=George
server-george:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=George
server-ringo:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=Ringo
server-ringo:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=Ringo
redis:
image: redis:alpine
expose:
- "6379"
redis:
image: redis:6
expose:
- "6379"

View File

@@ -51,4 +51,5 @@ RewriteRule /(.*) balancer://nodes_ws/$1 [P,L]
RewriteCond %{HTTP:Upgrade} !=websocket [NC]
RewriteRule /(.*) balancer://nodes_polling/$1 [P,L]
ProxyTimeout 3
# must be bigger than pingInterval (25s by default) + pingTimeout (20s by default)
ProxyTimeout 60

View File

@@ -1,2 +0,0 @@
FROM httpd:2.4-alpine
COPY ./httpd.conf /usr/local/apache2/conf/httpd.conf

View File

@@ -1,4 +1,4 @@
FROM mhart/alpine-node:6
FROM node:14-alpine
# Create app directory
RUN mkdir -p /usr/src/app

View File

@@ -1,58 +1,58 @@
services:
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
links:
- server-john
- server-paul
- server-george
- server-ringo
ports:
- "3000:80"
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
links:
- server-john
- server-paul
- server-george
- server-ringo
ports:
- "3000:80"
server-john:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=John
server-john:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=John
server-paul:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=Paul
server-paul:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=Paul
server-george:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=George
server-george:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=George
server-ringo:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=Ringo
server-ringo:
build: ./server
links:
- redis
expose:
- "3000"
environment:
- NAME=Ringo
client:
build: ./client
links:
- nginx
client:
build: ./client
links:
- nginx
redis:
image: redis:alpine
expose:
- "6379"
redis:
image: redis:6
expose:
- "6379"

View File

@@ -3,14 +3,14 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-scripts": "3.4.1",
"socket.io": "4",
"socket.io-client": "4"
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "^5.0.1",
"socket.io": "^4.6.1",
"socket.io-client": "^4.6.1"
},
"scripts": {
"start": "react-scripts start",

View File

@@ -1,14 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
ReactDOM.render(
const container = document.getElementById('root');
const root = createRoot(container)
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
</React.StrictMode>
);
// If you want your app to work offline and load faster, you can change

File diff suppressed because it is too large Load Diff

View File

@@ -8,18 +8,19 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"core-js": "^3.6.5",
"core-js": "^3.8.3",
"socket.io-client": "^4.0.0",
"vue": "^2.6.11"
"vue": "^2.6.14"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"vue-template-compiler": "^2.6.11"
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"vue-template-compiler": "^2.6.14"
},
"eslintConfig": {
"root": true,
@@ -31,9 +32,11 @@
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
"parser": "@babel/eslint-parser"
},
"rules": {}
"rules": {
"vue/multi-word-component-names": "off"
}
},
"browserslist": [
"> 1%",

View File

@@ -68,7 +68,7 @@ body {
@font-face {
font-family: Lato;
src: url("/fonts/Lato-Regular.ttf");
src: "~/public/fonts/Lato-Regular.ttf";
}
#app {

View File

@@ -7,6 +7,11 @@ import type {
EventNames,
EventsMap,
TypedEventBroadcaster,
DecorateAcknowledgements,
DecorateAcknowledgementsWithTimeoutAndMultipleResponses,
AllButLast,
Last,
SecondArg,
} from "./typed-events";
export class BroadcastOperator<EmitEvents extends EventsMap, SocketData>
@@ -16,7 +21,9 @@ export class BroadcastOperator<EmitEvents extends EventsMap, SocketData>
private readonly adapter: Adapter,
private readonly rooms: Set<Room> = new Set<Room>(),
private readonly exceptRooms: Set<Room> = new Set<Room>(),
private readonly flags: BroadcastFlags = {}
private readonly flags: BroadcastFlags & {
expectSingleResponse?: boolean;
} = {}
) {}
/**
@@ -169,12 +176,10 @@ export class BroadcastOperator<EmitEvents extends EventsMap, SocketData>
*/
public timeout(timeout: number) {
const flags = Object.assign({}, this.flags, { timeout });
return new BroadcastOperator<EmitEvents, SocketData>(
this.adapter,
this.rooms,
this.exceptRooms,
flags
);
return new BroadcastOperator<
DecorateAcknowledgementsWithTimeoutAndMultipleResponses<EmitEvents>,
SocketData
>(this.adapter, this.rooms, this.exceptRooms, flags);
}
/**
@@ -230,7 +235,10 @@ export class BroadcastOperator<EmitEvents extends EventsMap, SocketData>
const timer = setTimeout(() => {
timedOut = true;
ack.apply(this, [new Error("operation has timed out"), responses]);
ack.apply(this, [
new Error("operation has timed out"),
this.flags.expectSingleResponse ? null : responses,
]);
}, this.flags.timeout);
let expectedServerCount = -1;
@@ -244,7 +252,10 @@ export class BroadcastOperator<EmitEvents extends EventsMap, SocketData>
responses.length === expectedClientCount
) {
clearTimeout(timer);
ack.apply(this, [null, responses]);
ack.apply(this, [
null,
this.flags.expectSingleResponse ? null : responses,
]);
}
};
@@ -276,6 +287,36 @@ export class BroadcastOperator<EmitEvents extends EventsMap, SocketData>
return true;
}
/**
* Emits an event and waits for an acknowledgement from all clients.
*
* @example
* try {
* const responses = await io.timeout(1000).emitWithAck("some-event");
* console.log(responses); // one response per client
* } catch (e) {
* // some clients did not acknowledge the event in the given delay
* }
*
* @return a Promise that will be fulfilled when all clients have acknowledged the event
*/
public emitWithAck<Ev extends EventNames<EmitEvents>>(
ev: Ev,
...args: AllButLast<EventParams<EmitEvents, Ev>>
): Promise<SecondArg<Last<EventParams<EmitEvents, Ev>>>> {
return new Promise((resolve, reject) => {
args.push((err, responses) => {
if (err) {
err.responses = responses;
return reject(err);
} else {
return resolve(responses);
}
});
this.emit(ev, ...(args as any[] as EventParams<EmitEvents, Ev>));
});
}
/**
* Gets a list of clients.
*
@@ -444,10 +485,44 @@ export class RemoteSocket<EmitEvents extends EventsMap, SocketData>
this.data = details.data;
this.operator = new BroadcastOperator<EmitEvents, SocketData>(
adapter,
new Set([this.id])
new Set([this.id]),
new Set(),
{
expectSingleResponse: true, // so that remoteSocket.emit() with acknowledgement behaves like socket.emit()
}
);
}
/**
* Adds a timeout in milliseconds for the next operation.
*
* @example
* const sockets = await io.fetchSockets();
*
* for (const socket of sockets) {
* if (someCondition) {
* socket.timeout(1000).emit("some-event", (err) => {
* if (err) {
* // the client did not acknowledge the event in the given delay
* }
* });
* }
* }
*
* // note: if possible, using a room instead of looping over all sockets is preferable
* io.timeout(1000).to(someConditionRoom).emit("some-event", (err, responses) => {
* // ...
* });
*
* @param timeout
*/
public timeout(timeout: number) {
return this.operator.timeout(timeout) as BroadcastOperator<
DecorateAcknowledgements<EmitEvents>,
SocketData
>;
}
public emit<Ev extends EventNames<EmitEvents>>(
ev: Ev,
...args: EventParams<EmitEvents, Ev>

View File

@@ -114,7 +114,7 @@ export class Client<
* @param {Object} auth - the auth parameters
* @private
*/
private connect(name: string, auth: object = {}): void {
private connect(name: string, auth: Record<string, unknown> = {}): void {
if (this.server._nsps.has(name)) {
debug("connecting to namespace %s", name);
return this.doConnect(name, auth);
@@ -152,10 +152,10 @@ export class Client<
*
* @private
*/
private doConnect(name: string, auth: object): void {
private doConnect(name: string, auth: Record<string, unknown>): void {
const nsp = this.server.of(name);
const socket = nsp._add(this, auth, () => {
nsp._add(this, auth, (socket) => {
this.sockets.set(socket.id, socket);
this.nsps.set(nsp.name, socket);
@@ -228,7 +228,7 @@ export class Client<
}
private writeToEngine(
encodedPackets: Array<String | Buffer>,
encodedPackets: Array<string | Buffer>,
opts: WriteOptions
): void {
if (opts.volatile && !this.conn.transport.writable) {
@@ -267,7 +267,7 @@ export class Client<
*/
private ondecoded(packet: Packet): void {
let namespace: string;
let authPayload;
let authPayload: Record<string, unknown>;
if (this.conn.protocol === 3) {
const parsed = url.parse(packet.nsp, true);
namespace = parsed.pathname!;
@@ -311,9 +311,13 @@ export class Client<
* Called upon transport close.
*
* @param reason
* @param description
* @private
*/
private onclose(reason: CloseReason | "forced server close"): void {
private onclose(
reason: CloseReason | "forced server close",
description?: any
): void {
debug("client close with reason %s", reason);
// ignore a potential subsequent `close` event
@@ -321,7 +325,7 @@ export class Client<
// `nsps` and `sockets` are cleaned up seamlessly
for (const socket of this.sockets.values()) {
socket._onclose(reason);
socket._onclose(reason, description);
}
this.sockets.clear();

View File

@@ -6,22 +6,26 @@ import { createDeflate, createGzip, createBrotliCompress } from "zlib";
import accepts = require("accepts");
import { pipeline } from "stream";
import path = require("path");
import {
attach,
Server as Engine,
import { attach, Server as Engine, uServer } from "engine.io";
import type {
ServerOptions as EngineOptions,
AttachOptions,
uServer,
BaseServer,
} from "engine.io";
import { Client } from "./client";
import { EventEmitter } from "events";
import { ExtendedError, Namespace, ServerReservedEventsMap } from "./namespace";
import { ParentNamespace } from "./parent-namespace";
import { Adapter, Room, SocketId } from "socket.io-adapter";
import {
Adapter,
SessionAwareAdapter,
Room,
SocketId,
} from "socket.io-adapter";
import * as parser from "socket.io-parser";
import type { Encoder } from "socket.io-parser";
import debugModule from "debug";
import { Socket } from "./socket";
import { Socket, DisconnectReason } from "./socket";
import type { BroadcastOperator, RemoteSocket } from "./broadcast-operator";
import {
EventsMap,
@@ -29,8 +33,14 @@ import {
EventParams,
StrictEventEmitter,
EventNames,
DecorateAcknowledgementsWithTimeoutAndMultipleResponses,
AllButLast,
Last,
FirstArg,
SecondArg,
} from "./typed-events";
import { patchAdapter, restoreAdapter, serveFile } from "./uws";
import corsMiddleware from "cors";
const debug = debugModule("socket.io:server");
@@ -71,6 +81,30 @@ interface ServerOptions extends EngineOptions, AttachOptions {
* @default 45000
*/
connectTimeout: number;
/**
* Whether to enable the recovery of connection state when a client temporarily disconnects.
*
* The connection state includes the missed packets, the rooms the socket was in and the `data` attribute.
*/
connectionStateRecovery: {
/**
* The backup duration of the sessions and the packets.
*
* @default 120000 (2 minutes)
*/
maxDisconnectionDuration?: number;
/**
* Whether to skip middlewares upon successful connection state recovery.
*
* @default true
*/
skipMiddlewares?: boolean;
};
/**
* Whether to remove child namespaces that have no sockets connected to them
* @default false
*/
cleanupEmptyChildNamespaces: boolean;
}
/**
@@ -127,7 +161,7 @@ export class Server<
* const clientsCount = io.engine.clientsCount;
*
*/
public engine: any;
public engine: BaseServer;
/** @private */
readonly _parser: typeof parser;
@@ -145,9 +179,21 @@ export class Server<
ParentNspNameMatchFn,
ParentNamespace<ListenEvents, EmitEvents, ServerSideEvents, SocketData>
> = new Map();
/**
* A subset of the {@link parentNsps} map, only containing {@link ParentNamespace} which are based on a regular
* expression.
*
* @private
*/
private parentNamespacesFromRegExp: Map<
RegExp,
ParentNamespace<ListenEvents, EmitEvents, ServerSideEvents, SocketData>
> = new Map();
private _adapter?: AdapterConstructor;
private _serveClient: boolean;
private opts: Partial<EngineOptions>;
private readonly opts: Partial<ServerOptions>;
private eio: Engine;
private _path: string;
private clientPathRegex: RegExp;
@@ -157,6 +203,11 @@ export class Server<
*/
_connectTimeout: number;
private httpServer: http.Server | HTTPSServer | Http2SecureServer;
private _corsMiddleware: (
req: http.IncomingMessage,
res: http.ServerResponse,
next: () => void
) => void;
/**
* Server constructor.
@@ -203,13 +254,33 @@ export class Server<
this.serveClient(false !== opts.serveClient);
this._parser = opts.parser || parser;
this.encoder = new this._parser.Encoder();
this.adapter(opts.adapter || Adapter);
this.sockets = this.of("/");
this.opts = opts;
if (opts.connectionStateRecovery) {
opts.connectionStateRecovery = Object.assign(
{
maxDisconnectionDuration: 2 * 60 * 1000,
skipMiddlewares: true,
},
opts.connectionStateRecovery
);
this.adapter(opts.adapter || SessionAwareAdapter);
} else {
this.adapter(opts.adapter || Adapter);
}
opts.cleanupEmptyChildNamespaces = !!opts.cleanupEmptyChildNamespaces;
this.sockets = this.of("/");
if (srv || typeof srv == "number")
this.attach(
srv as http.Server | HTTPSServer | Http2SecureServer | number
);
if (this.opts.cors) {
this._corsMiddleware = corsMiddleware(this.opts.cors);
}
}
get _opts() {
return this.opts;
}
/**
@@ -265,8 +336,6 @@ export class Server<
}
const namespace = this.parentNsps.get(nextFn.value)!.createChild(name);
debug("dynamic namespace %s was created", name);
// @ts-ignore
this.sockets.emitReserved("new_namespace", namespace);
fn(namespace);
});
};
@@ -437,7 +506,7 @@ export class Server<
res.writeHeader("cache-control", "public, max-age=0");
res.writeHeader(
"content-type",
"application/" + (isMap ? "json" : "javascript")
"application/" + (isMap ? "json" : "javascript") + "; charset=utf-8"
);
res.writeHeader("etag", expectedEtag);
@@ -489,7 +558,13 @@ export class Server<
srv.removeAllListeners("request");
srv.on("request", (req, res) => {
if (this.clientPathRegex.test(req.url!)) {
this.serve(req, res);
if (this._corsMiddleware) {
this._corsMiddleware(req, res, () => {
this.serve(req, res);
});
} else {
this.serve(req, res);
}
} else {
for (let i = 0; i < evs.length; i++) {
evs[i].call(srv, req, res);
@@ -530,7 +605,7 @@ export class Server<
res.setHeader("Cache-Control", "public, max-age=0");
res.setHeader(
"Content-Type",
"application/" + (isMap ? "json" : "javascript")
"application/" + (isMap ? "json" : "javascript") + "; charset=utf-8"
);
res.setHeader("ETag", expectedEtag);
@@ -582,10 +657,10 @@ export class Server<
/**
* Binds socket.io to an engine.io instance.
*
* @param {engine.Server} engine engine.io (or compatible) server
* @param engine engine.io (or compatible) server
* @return self
*/
public bind(engine): this {
public bind(engine: BaseServer): this {
this.engine = engine;
this.engine.on("connection", this.onconnection.bind(this));
return this;
@@ -642,6 +717,7 @@ export class Server<
(nsp, conn, next) => next(null, (name as RegExp).test(nsp)),
parentNsp
);
this.parentNamespacesFromRegExp.set(name, parentNsp);
}
if (fn) {
// @ts-ignore
@@ -654,6 +730,13 @@ export class Server<
let nsp = this._nsps.get(name);
if (!nsp) {
for (const [regex, parentNamespace] of this.parentNamespacesFromRegExp) {
if (regex.test(name as string)) {
debug("attaching namespace %s to parent namespace %s", name, regex);
return parentNamespace.createChild(name as string);
}
}
debug("initializing namespace %s", name);
nsp = new Namespace(this, name);
this._nsps.set(name, nsp);
@@ -763,6 +846,26 @@ export class Server<
return this.sockets.except(room);
}
/**
* Emits an event and waits for an acknowledgement from all clients.
*
* @example
* try {
* const responses = await io.timeout(1000).emitWithAck("some-event");
* console.log(responses); // one response per client
* } catch (e) {
* // some clients did not acknowledge the event in the given delay
* }
*
* @return a Promise that will be fulfilled when all clients have acknowledged the event
*/
public emitWithAck<Ev extends EventNames<EmitEvents>>(
ev: Ev,
...args: AllButLast<EventParams<EmitEvents, Ev>>
): Promise<SecondArg<Last<EventParams<EmitEvents, Ev>>>> {
return this.sockets.emitWithAck(ev, ...args);
}
/**
* Sends a `message` event to all clients.
*
@@ -806,9 +909,9 @@ export class Server<
* // acknowledgements (without binary content) are supported too:
* io.serverSideEmit("ping", (err, responses) => {
* if (err) {
* // some clients did not acknowledge the event in the given delay
* // some servers did not acknowledge the event in the given delay
* } else {
* console.log(responses); // one response per client
* console.log(responses); // one response per server (except the current one)
* }
* });
*
@@ -821,11 +924,37 @@ export class Server<
*/
public serverSideEmit<Ev extends EventNames<ServerSideEvents>>(
ev: Ev,
...args: EventParams<ServerSideEvents, Ev>
...args: EventParams<
DecorateAcknowledgementsWithTimeoutAndMultipleResponses<ServerSideEvents>,
Ev
>
): boolean {
return this.sockets.serverSideEmit(ev, ...args);
}
/**
* Sends a message and expect an acknowledgement from the other Socket.IO servers of the cluster.
*
* @example
* try {
* const responses = await io.serverSideEmitWithAck("ping");
* console.log(responses); // one response per server (except the current one)
* } catch (e) {
* // some servers did not acknowledge the event in the given delay
* }
*
* @param ev - the event name
* @param args - an array of arguments
*
* @return a Promise that will be fulfilled when all servers have acknowledged the event
*/
public serverSideEmitWithAck<Ev extends EventNames<ServerSideEvents>>(
ev: Ev,
...args: AllButLast<EventParams<ServerSideEvents, Ev>>
): Promise<FirstArg<Last<EventParams<ServerSideEvents, Ev>>>[]> {
return this.sockets.serverSideEmitWithAck(ev, ...args);
}
/**
* Gets a list of socket ids.
*
@@ -999,5 +1128,12 @@ module.exports.Server = Server;
module.exports.Namespace = Namespace;
module.exports.Socket = Socket;
export { Socket, ServerOptions, Namespace, BroadcastOperator, RemoteSocket };
export {
Socket,
DisconnectReason,
ServerOptions,
Namespace,
BroadcastOperator,
RemoteSocket,
};
export { Event } from "./socket";

View File

@@ -6,11 +6,16 @@ import {
EventsMap,
StrictEventEmitter,
DefaultEventsMap,
DecorateAcknowledgementsWithTimeoutAndMultipleResponses,
AllButLast,
Last,
FirstArg,
SecondArg,
} from "./typed-events";
import type { Client } from "./client";
import debugModule from "debug";
import type { Adapter, Room, SocketId } from "socket.io-adapter";
import { BroadcastOperator, RemoteSocket } from "./broadcast-operator";
import { BroadcastOperator } from "./broadcast-operator";
const debug = debugModule("socket.io:namespace");
@@ -296,13 +301,25 @@ export class Namespace<
* @return {Socket}
* @private
*/
_add(
async _add(
client: Client<ListenEvents, EmitEvents, ServerSideEvents>,
query,
fn?: () => void
): Socket<ListenEvents, EmitEvents, ServerSideEvents, SocketData> {
auth: Record<string, unknown>,
fn: (
socket: Socket<ListenEvents, EmitEvents, ServerSideEvents, SocketData>
) => void
) {
debug("adding socket to nsp %s", this.name);
const socket = new Socket(this, client, query);
const socket = await this._createSocket(client, auth);
if (
// @ts-ignore
this.server.opts.connectionStateRecovery?.skipMiddlewares &&
socket.recovered &&
client.conn.readyState === "open"
) {
return this._doConnect(socket, fn);
}
this.run(socket, (err) => {
process.nextTick(() => {
if ("open" !== client.conn.readyState) {
@@ -324,22 +341,56 @@ export class Namespace<
}
}
// track socket
this.sockets.set(socket.id, socket);
// it's paramount that the internal `onconnect` logic
// fires before user-set events to prevent state order
// violations (such as a disconnection before the connection
// logic is complete)
socket._onconnect();
if (fn) fn();
// fire user-set events
this.emitReserved("connect", socket);
this.emitReserved("connection", socket);
this._doConnect(socket, fn);
});
});
return socket;
}
private async _createSocket(
client: Client<ListenEvents, EmitEvents, ServerSideEvents>,
auth: Record<string, unknown>
) {
const sessionId = auth.pid;
const offset = auth.offset;
if (
// @ts-ignore
this.server.opts.connectionStateRecovery &&
typeof sessionId === "string" &&
typeof offset === "string"
) {
let session;
try {
session = await this.adapter.restoreSession(sessionId, offset);
} catch (e) {
debug("error while restoring session: %s", e);
}
if (session) {
debug("connection state recovered for sid %s", session.sid);
return new Socket(this, client, auth, session);
}
}
return new Socket(this, client, auth);
}
private _doConnect(
socket: Socket<ListenEvents, EmitEvents, ServerSideEvents, SocketData>,
fn: (
socket: Socket<ListenEvents, EmitEvents, ServerSideEvents, SocketData>
) => void
) {
// track socket
this.sockets.set(socket.id, socket);
// it's paramount that the internal `onconnect` logic
// fires before user-set events to prevent state order
// violations (such as a disconnection before the connection
// logic is complete)
socket._onconnect();
if (fn) fn(socket);
// fire user-set events
this.emitReserved("connect", socket);
this.emitReserved("connection", socket);
}
/**
@@ -389,6 +440,30 @@ export class Namespace<
);
}
/**
* Emits an event and waits for an acknowledgement from all clients.
*
* @example
* const myNamespace = io.of("/my-namespace");
*
* try {
* const responses = await myNamespace.timeout(1000).emitWithAck("some-event");
* console.log(responses); // one response per client
* } catch (e) {
* // some clients did not acknowledge the event in the given delay
* }
*
* @return a Promise that will be fulfilled when all clients have acknowledged the event
*/
public emitWithAck<Ev extends EventNames<EmitEvents>>(
ev: Ev,
...args: AllButLast<EventParams<EmitEvents, Ev>>
): Promise<SecondArg<Last<EventParams<EmitEvents, Ev>>>> {
return new BroadcastOperator<EmitEvents, SocketData>(
this.adapter
).emitWithAck(ev, ...args);
}
/**
* Sends a `message` event to all clients.
*
@@ -436,9 +511,9 @@ export class Namespace<
* // acknowledgements (without binary content) are supported too:
* myNamespace.serverSideEmit("ping", (err, responses) => {
* if (err) {
* // some clients did not acknowledge the event in the given delay
* // some servers did not acknowledge the event in the given delay
* } else {
* console.log(responses); // one response per client
* console.log(responses); // one response per server (except the current one)
* }
* });
*
@@ -451,7 +526,10 @@ export class Namespace<
*/
public serverSideEmit<Ev extends EventNames<ServerSideEvents>>(
ev: Ev,
...args: EventParams<ServerSideEvents, Ev>
...args: EventParams<
DecorateAcknowledgementsWithTimeoutAndMultipleResponses<ServerSideEvents>,
Ev
>
): boolean {
if (RESERVED_EVENTS.has(ev)) {
throw new Error(`"${String(ev)}" is a reserved event name`);
@@ -461,6 +539,44 @@ export class Namespace<
return true;
}
/**
* Sends a message and expect an acknowledgement from the other Socket.IO servers of the cluster.
*
* @example
* const myNamespace = io.of("/my-namespace");
*
* try {
* const responses = await myNamespace.serverSideEmitWithAck("ping");
* console.log(responses); // one response per server (except the current one)
* } catch (e) {
* // some servers did not acknowledge the event in the given delay
* }
*
* @param ev - the event name
* @param args - an array of arguments
*
* @return a Promise that will be fulfilled when all servers have acknowledged the event
*/
public serverSideEmitWithAck<Ev extends EventNames<ServerSideEvents>>(
ev: Ev,
...args: AllButLast<EventParams<ServerSideEvents, Ev>>
): Promise<FirstArg<Last<EventParams<ServerSideEvents, Ev>>>[]> {
return new Promise((resolve, reject) => {
args.push((err, responses) => {
if (err) {
err.responses = responses;
return reject(err);
} else {
return resolve(responses);
}
});
this.serverSideEmit(
ev,
...(args as any[] as EventParams<ServerSideEvents, Ev>)
);
});
}
/**
* Called when a packet is received from another Socket.IO server
*

View File

@@ -7,7 +7,25 @@ import type {
DefaultEventsMap,
} from "./typed-events";
import type { BroadcastOptions } from "socket.io-adapter";
import debugModule from "debug";
const debug = debugModule("socket.io:parent-namespace");
/**
* A parent namespace is a special {@link Namespace} that holds a list of child namespaces which were created either
* with a regular expression or with a function.
*
* @example
* const parentNamespace = io.of(/\/dynamic-\d+/);
*
* parentNamespace.on("connection", (socket) => {
* const childNamespace = socket.nsp;
* }
*
* // will reach all the clients that are in one of the child namespaces, like "/dynamic-101"
* parentNamespace.emit("hello", "world");
*
*/
export class ParentNamespace<
ListenEvents extends EventsMap = DefaultEventsMap,
EmitEvents extends EventsMap = ListenEvents,
@@ -52,6 +70,7 @@ export class ParentNamespace<
createChild(
name: string
): Namespace<ListenEvents, EmitEvents, ServerSideEvents, SocketData> {
debug("creating child namespace %s", name);
const namespace = new Namespace(this.server, name);
namespace._fns = this._fns.slice(0);
this.listeners("connect").forEach((listener) =>
@@ -61,7 +80,26 @@ export class ParentNamespace<
namespace.on("connection", listener)
);
this.children.add(namespace);
if (this.server._opts.cleanupEmptyChildNamespaces) {
const remove = namespace._remove;
namespace._remove = (socket) => {
remove.call(namespace, socket);
if (namespace.sockets.size === 0) {
debug("closing child namespace %s", name);
namespace.adapter.close();
this.server._nsps.delete(namespace.name);
this.children.delete(namespace);
}
};
}
this.server._nsps.set(name, namespace);
// @ts-ignore
this.server.sockets.emitReserved("new_namespace", namespace);
return namespace;
}

View File

@@ -2,19 +2,26 @@ import { Packet, PacketType } from "socket.io-parser";
import debugModule from "debug";
import type { Server } from "./index";
import {
EventParams,
EventNames,
EventsMap,
StrictEventEmitter,
AllButLast,
DecorateAcknowledgements,
DecorateAcknowledgementsWithMultipleResponses,
DefaultEventsMap,
EventNames,
EventParams,
EventsMap,
FirstArg,
Last,
StrictEventEmitter,
} from "./typed-events";
import type { Client } from "./client";
import type { Namespace, NamespaceReservedEventsMap } from "./namespace";
import type { IncomingMessage, IncomingHttpHeaders } from "http";
import type { IncomingHttpHeaders, IncomingMessage } from "http";
import type {
Adapter,
BroadcastFlags,
PrivateSessionId,
Room,
Session,
SocketId,
} from "socket.io-adapter";
import base64id from "base64id";
@@ -39,9 +46,18 @@ export type DisconnectReason =
| "client namespace disconnect"
| "server namespace disconnect";
const RECOVERABLE_DISCONNECT_REASONS: ReadonlySet<DisconnectReason> = new Set([
"transport error",
"transport close",
"forced close",
"ping timeout",
"server shutting down",
"forced server close",
]);
export interface SocketReservedEventsMap {
disconnect: (reason: DisconnectReason) => void;
disconnecting: (reason: DisconnectReason) => void;
disconnect: (reason: DisconnectReason, description?: any) => void;
disconnecting: (reason: DisconnectReason, description?: any) => void;
error: (err: Error) => void;
}
@@ -173,6 +189,11 @@ export class Socket<
* An unique identifier for the session.
*/
public readonly id: SocketId;
/**
* Whether the connection state was recovered after a temporary disconnection. In that case, any missed packets will
* be transmitted to the client, the data attribute and the rooms will be restored.
*/
public readonly recovered: boolean = false;
/**
* The handshake details.
*/
@@ -181,7 +202,7 @@ export class Socket<
* Additional information that can be attached to the Socket instance and which will be used in the
* {@link Server.fetchSockets()} method.
*/
public data: Partial<SocketData> = {};
public data: SocketData = {} as SocketData;
/**
* Whether the socket is currently connected or not.
*
@@ -197,6 +218,14 @@ export class Socket<
*/
public connected: boolean = false;
/**
* The session ID, which must not be shared (unlike {@link id}).
*
* @private
*/
private readonly pid: PrivateSessionId;
// TODO: remove this unused reference
private readonly server: Server<
ListenEvents,
EmitEvents,
@@ -221,18 +250,39 @@ export class Socket<
constructor(
readonly nsp: Namespace<ListenEvents, EmitEvents, ServerSideEvents>,
readonly client: Client<ListenEvents, EmitEvents, ServerSideEvents>,
auth: object
auth: Record<string, unknown>,
previousSession?: Session
) {
super();
this.server = nsp.server;
this.adapter = this.nsp.adapter;
if (client.conn.protocol === 3) {
// @ts-ignore
this.id = nsp.name !== "/" ? nsp.name + "#" + client.id : client.id;
if (previousSession) {
this.id = previousSession.sid;
this.pid = previousSession.pid;
previousSession.rooms.forEach((room) => this.join(room));
this.data = previousSession.data as SocketData;
previousSession.missedPackets.forEach((packet) => {
this.packet({
type: PacketType.EVENT,
data: packet,
});
});
this.recovered = true;
} else {
this.id = base64id.generateId(); // don't reuse the Engine.IO id because it's sensitive information
if (client.conn.protocol === 3) {
// @ts-ignore
this.id = nsp.name !== "/" ? nsp.name + "#" + client.id : client.id;
} else {
this.id = base64id.generateId(); // don't reuse the Engine.IO id because it's sensitive information
}
if (this.server._opts.connectionStateRecovery) {
this.pid = base64id.generateId();
}
}
this.handshake = this.buildHandshake(auth);
// prevents crash when the socket receives an "error" event without listener
this.on("error", noop);
}
/**
@@ -299,12 +349,58 @@ export class Socket<
const flags = Object.assign({}, this.flags);
this.flags = {};
this.notifyOutgoingListeners(packet);
this.packet(packet, flags);
// @ts-ignore
if (this.nsp.server.opts.connectionStateRecovery) {
// this ensures the packet is stored and can be transmitted upon reconnection
this.adapter.broadcast(packet, {
rooms: new Set([this.id]),
except: new Set(),
flags,
});
} else {
this.notifyOutgoingListeners(packet);
this.packet(packet, flags);
}
return true;
}
/**
* Emits an event and waits for an acknowledgement
*
* @example
* io.on("connection", async (socket) => {
* // without timeout
* const response = await socket.emitWithAck("hello", "world");
*
* // with a specific timeout
* try {
* const response = await socket.timeout(1000).emitWithAck("hello", "world");
* } catch (err) {
* // the client did not acknowledge the event in the given delay
* }
* });
*
* @return a Promise that will be fulfilled when the client acknowledges the event
*/
public emitWithAck<Ev extends EventNames<EmitEvents>>(
ev: Ev,
...args: AllButLast<EventParams<EmitEvents, Ev>>
): Promise<FirstArg<Last<EventParams<EmitEvents, Ev>>>> {
// the timeout flag is optional
const withErr = this.flags.timeout !== undefined;
return new Promise((resolve, reject) => {
args.push((arg1, arg2) => {
if (withErr) {
return arg1 ? reject(arg1) : resolve(arg2);
} else {
return resolve(arg1);
}
});
this.emit(ev, ...(args as any[] as EventParams<EmitEvents, Ev>));
});
}
/**
* @private
*/
@@ -508,7 +604,10 @@ export class Socket<
if (this.conn.protocol === 3) {
this.packet({ type: PacketType.CONNECT });
} else {
this.packet({ type: PacketType.CONNECT, data: { sid: this.id } });
this.packet({
type: PacketType.CONNECT,
data: { sid: this.id, pid: this.pid },
});
}
}
@@ -624,31 +723,45 @@ export class Socket<
* @private
*/
_onerror(err: Error): void {
if (this.listeners("error").length) {
this.emitReserved("error", err);
} else {
console.error("Missing error handler on `socket`.");
console.error(err.stack);
}
// FIXME the meaning of the "error" event is overloaded:
// - it can be sent by the client (`socket.emit("error")`)
// - it can be emitted when the connection encounters an error (an invalid packet for example)
// - it can be emitted when a packet is rejected in a middleware (`socket.use()`)
this.emitReserved("error", err);
}
/**
* Called upon closing. Called by `Client`.
*
* @param {String} reason
* @param description
* @throw {Error} optional error object
*
* @private
*/
_onclose(reason: DisconnectReason): this | undefined {
_onclose(reason: DisconnectReason, description?: any): this | undefined {
if (!this.connected) return this;
debug("closing socket - reason %s", reason);
this.emitReserved("disconnecting", reason);
this.emitReserved("disconnecting", reason, description);
if (
this.server._opts.connectionStateRecovery &&
RECOVERABLE_DISCONNECT_REASONS.has(reason)
) {
debug("connection state recovery is enabled for sid %s", this.id);
this.adapter.persistSession({
sid: this.id,
pid: this.pid,
rooms: [...this.rooms],
data: this.data,
});
}
this._cleanup();
this.nsp._remove(this);
this.client._remove(this);
this.connected = false;
this.emitReserved("disconnect", reason);
this.emitReserved("disconnect", reason, description);
return;
}
@@ -778,7 +891,14 @@ export class Socket<
*
* @returns self
*/
public timeout(timeout: number): this {
public timeout(
timeout: number
): Socket<
ListenEvents,
DecorateAcknowledgements<EmitEvents>,
ServerSideEvents,
SocketData
> {
this.flags.timeout = timeout;
return this;
}
@@ -1088,11 +1208,9 @@ export class Socket<
private newBroadcastOperator() {
const flags = Object.assign({}, this.flags);
this.flags = {};
return new BroadcastOperator<EmitEvents, SocketData>(
this.adapter,
new Set<Room>(),
new Set<Room>([this.id]),
flags
);
return new BroadcastOperator<
DecorateAcknowledgementsWithMultipleResponses<EmitEvents>,
SocketData
>(this.adapter, new Set<Room>(), new Set<Room>([this.id]), flags);
}
}

View File

@@ -178,3 +178,66 @@ export abstract class StrictEventEmitter<
>[];
}
}
export type Last<T extends any[]> = T extends [...infer H, infer L] ? L : any;
export type AllButLast<T extends any[]> = T extends [...infer H, infer L]
? H
: any[];
export type FirstArg<T> = T extends (arg: infer Param) => infer Result
? Param
: any;
export type SecondArg<T> = T extends (
err: Error,
arg: infer Param
) => infer Result
? Param
: any;
type PrependTimeoutError<T extends any[]> = {
[K in keyof T]: T[K] extends (...args: infer Params) => infer Result
? (err: Error, ...args: Params) => Result
: T[K];
};
type ExpectMultipleResponses<T extends any[]> = {
[K in keyof T]: T[K] extends (err: Error, arg: infer Param) => infer Result
? (err: Error, arg: Param[]) => Result
: T[K];
};
/**
* Utility type to decorate the acknowledgement callbacks with a timeout error.
*
* This is needed because the timeout() flag breaks the symmetry between the sender and the receiver:
*
* @example
* interface Events {
* "my-event": (val: string) => void;
* }
*
* socket.on("my-event", (cb) => {
* cb("123"); // one single argument here
* });
*
* socket.timeout(1000).emit("my-event", (err, val) => {
* // two arguments there (the "err" argument is not properly typed)
* });
*
*/
export type DecorateAcknowledgements<E> = {
[K in keyof E]: E[K] extends (...args: infer Params) => infer Result
? (...args: PrependTimeoutError<Params>) => Result
: E[K];
};
export type DecorateAcknowledgementsWithTimeoutAndMultipleResponses<E> = {
[K in keyof E]: E[K] extends (...args: infer Params) => infer Result
? (...args: ExpectMultipleResponses<PrependTimeoutError<Params>>) => Result
: E[K];
};
export type DecorateAcknowledgementsWithMultipleResponses<E> = {
[K in keyof E]: E[K] extends (...args: infer Params) => infer Result
? (...args: ExpectMultipleResponses<Params>) => Result
: E[K];
};

221
package-lock.json generated
View File

@@ -1,20 +1,21 @@
{
"name": "socket.io",
"version": "4.5.3",
"version": "4.7.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "socket.io",
"version": "4.5.3",
"version": "4.7.0",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"engine.io": "~6.2.1",
"socket.io-adapter": "~2.4.0",
"socket.io-parser": "~4.2.1"
"engine.io": "~6.5.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"devDependencies": {
"@types/mocha": "^9.0.0",
@@ -23,14 +24,14 @@
"nyc": "^15.1.0",
"prettier": "^2.3.2",
"rimraf": "^3.0.2",
"socket.io-client": "4.5.4",
"socket.io-client": "4.7.1",
"socket.io-client-v2": "npm:socket.io-client@^2.4.0",
"superagent": "^8.0.0",
"supertest": "^6.1.6",
"ts-node": "^10.2.1",
"tsd": "^0.21.0",
"typescript": "^4.4.2",
"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.0.0"
"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.30.0"
},
"engines": {
"node": ">=10.0.0"
@@ -1228,9 +1229,9 @@
}
},
"node_modules/cookiejar": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz",
"integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true
},
"node_modules/cors": {
@@ -1334,9 +1335,9 @@
}
},
"node_modules/dezalgo": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.3.tgz",
"integrity": "sha512-K7i4zNfT2kgQz3GylDw40ot9GAE47sFZ9EXHFSPP6zONLgH6kWXE0KWJchkbQJLBkRazq4APwZ4OwiFFlT95OQ==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
"dev": true,
"dependencies": {
"asap": "^2.0.0",
@@ -1377,9 +1378,9 @@
"dev": true
},
"node_modules/engine.io": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.2.1.tgz",
"integrity": "sha512-ECceEFcAaNRybd3lsGQKas3ZlMVjN3cyWwMP25D2i0zWfyiytVbTpRPa34qrr+FHddtpBVOmq4H/DCv1O0lZRA==",
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.0.tgz",
"integrity": "sha512-UlfoK1iD62Hkedw2TmuHdhDsZCGaAyp+LZ/AvnImjYBeWagA3qIEETum90d6shMeFZiDuGT66zVCdx1wKYKGGg==",
"dependencies": {
"@types/cookie": "^0.4.1",
"@types/cors": "^2.8.12",
@@ -1389,30 +1390,30 @@
"cookie": "~0.4.1",
"cors": "~2.8.5",
"debug": "~4.3.1",
"engine.io-parser": "~5.0.3",
"ws": "~8.2.3"
"engine.io-parser": "~5.1.0",
"ws": "~8.11.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io-client": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.2.3.tgz",
"integrity": "sha512-aXPtgF1JS3RuuKcpSrBtimSjYvrbhKW9froICH4s0F3XQWLxsKNxqzG39nnvQZQnva4CMvUK63T7shevxRyYHw==",
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.1.tgz",
"integrity": "sha512-hE5wKXH8Ru4L19MbM1GgYV/2Qo54JSMh1rlJbfpa40bEWkCKNo3ol2eOtGmowcr+ysgbI7+SGL+by42Q3pt/Ng==",
"dev": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.0.3",
"ws": "~8.2.3",
"engine.io-parser": "~5.1.0",
"ws": "~8.11.0",
"xmlhttprequest-ssl": "~2.0.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.4.tgz",
"integrity": "sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.1.0.tgz",
"integrity": "sha512-enySgNiK5tyZFynt3z7iqBR+Bto9EVVVvDFuTT0ioHCGbzirZVGDGiQjZzEp8hWl6hd5FSVytJGuScX1C1C35w==",
"engines": {
"node": ">=10.0.0"
}
@@ -1613,32 +1614,20 @@
}
},
"node_modules/formidable": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz",
"integrity": "sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.1.tgz",
"integrity": "sha512-0EcS9wCFEzLvfiks7omJ+SiYJAiD+TzK4Pcw1UlUoGnhUxDcMKjt0P7x8wEb0u6OHu8Nb98WG3nxtlF5C7bvUQ==",
"dev": true,
"dependencies": {
"dezalgo": "1.0.3",
"hexoid": "1.0.0",
"once": "1.4.0",
"qs": "6.9.3"
"dezalgo": "^1.0.4",
"hexoid": "^1.0.0",
"once": "^1.4.0",
"qs": "^6.11.0"
},
"funding": {
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/formidable/node_modules/qs": {
"version": "6.9.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz",
"integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==",
"dev": true,
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fromentries": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz",
@@ -2254,9 +2243,9 @@
"dev": true
},
"node_modules/json5": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
"integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"bin": {
"json5": "lib/cli.js"
@@ -3467,20 +3456,23 @@
}
},
"node_modules/socket.io-adapter": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.4.0.tgz",
"integrity": "sha512-W4N+o69rkMEGVuk2D/cvca3uYsvGlMwsySWV447y99gUPghxq42BxqLNMndb+a1mm/5/7NeXVQS7RLa2XyXvYg=="
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.2.tgz",
"integrity": "sha512-87C3LO/NOMc+eMcpcxUBebGjkpMDkNBS9tf7KJqcDsmL936EChtVva71Dw2q4tQcuVC+hAUy4an2NO/sYXmwRA==",
"dependencies": {
"ws": "~8.11.0"
}
},
"node_modules/socket.io-client": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.5.4.tgz",
"integrity": "sha512-ZpKteoA06RzkD32IbqILZ+Cnst4xewU7ZYK12aS1mzHftFFjpoMz69IuhP/nL25pJfao/amoPI527KnuhFm01g==",
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.1.tgz",
"integrity": "sha512-Qk3Xj8ekbnzKu3faejo4wk2MzXA029XppiXtTF/PkbTg+fcwaTw1PlDrTrrrU4mKoYC4dvlApOnSeyLCKwek2w==",
"dev": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.2.3",
"socket.io-parser": "~4.2.1"
"engine.io-client": "~6.5.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
@@ -3595,9 +3587,9 @@
}
},
"node_modules/socket.io-parser": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.1.tgz",
"integrity": "sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
@@ -4114,8 +4106,8 @@
}
},
"node_modules/uWebSockets.js": {
"version": "20.0.0",
"resolved": "git+https://git@github.com/uNetworking/uWebSockets.js.git#4558ee00f9f1f686fffe1accbfc2e85b1af9c50f",
"version": "20.30.0",
"resolved": "git+https://git@github.com/uNetworking/uWebSockets.js.git#d39d4181daf5b670d44cbc1b18f8c28c85fd4142",
"dev": true
},
"node_modules/v8-compile-cache-lib": {
@@ -4205,9 +4197,9 @@
}
},
"node_modules/ws": {
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
"integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==",
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
"integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
"engines": {
"node": ">=10.0.0"
},
@@ -5280,9 +5272,9 @@
"integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA=="
},
"cookiejar": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz",
"integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true
},
"cors": {
@@ -5359,9 +5351,9 @@
"dev": true
},
"dezalgo": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.3.tgz",
"integrity": "sha512-K7i4zNfT2kgQz3GylDw40ot9GAE47sFZ9EXHFSPP6zONLgH6kWXE0KWJchkbQJLBkRazq4APwZ4OwiFFlT95OQ==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
"dev": true,
"requires": {
"asap": "^2.0.0",
@@ -5396,9 +5388,9 @@
"dev": true
},
"engine.io": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.2.1.tgz",
"integrity": "sha512-ECceEFcAaNRybd3lsGQKas3ZlMVjN3cyWwMP25D2i0zWfyiytVbTpRPa34qrr+FHddtpBVOmq4H/DCv1O0lZRA==",
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.0.tgz",
"integrity": "sha512-UlfoK1iD62Hkedw2TmuHdhDsZCGaAyp+LZ/AvnImjYBeWagA3qIEETum90d6shMeFZiDuGT66zVCdx1wKYKGGg==",
"requires": {
"@types/cookie": "^0.4.1",
"@types/cors": "^2.8.12",
@@ -5408,27 +5400,27 @@
"cookie": "~0.4.1",
"cors": "~2.8.5",
"debug": "~4.3.1",
"engine.io-parser": "~5.0.3",
"ws": "~8.2.3"
"engine.io-parser": "~5.1.0",
"ws": "~8.11.0"
}
},
"engine.io-client": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.2.3.tgz",
"integrity": "sha512-aXPtgF1JS3RuuKcpSrBtimSjYvrbhKW9froICH4s0F3XQWLxsKNxqzG39nnvQZQnva4CMvUK63T7shevxRyYHw==",
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.1.tgz",
"integrity": "sha512-hE5wKXH8Ru4L19MbM1GgYV/2Qo54JSMh1rlJbfpa40bEWkCKNo3ol2eOtGmowcr+ysgbI7+SGL+by42Q3pt/Ng==",
"dev": true,
"requires": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.0.3",
"ws": "~8.2.3",
"engine.io-parser": "~5.1.0",
"ws": "~8.11.0",
"xmlhttprequest-ssl": "~2.0.0"
}
},
"engine.io-parser": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.4.tgz",
"integrity": "sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg=="
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.1.0.tgz",
"integrity": "sha512-enySgNiK5tyZFynt3z7iqBR+Bto9EVVVvDFuTT0ioHCGbzirZVGDGiQjZzEp8hWl6hd5FSVytJGuScX1C1C35w=="
},
"error-ex": {
"version": "1.3.2",
@@ -5577,23 +5569,15 @@
}
},
"formidable": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz",
"integrity": "sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.1.tgz",
"integrity": "sha512-0EcS9wCFEzLvfiks7omJ+SiYJAiD+TzK4Pcw1UlUoGnhUxDcMKjt0P7x8wEb0u6OHu8Nb98WG3nxtlF5C7bvUQ==",
"dev": true,
"requires": {
"dezalgo": "1.0.3",
"hexoid": "1.0.0",
"once": "1.4.0",
"qs": "6.9.3"
},
"dependencies": {
"qs": {
"version": "6.9.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz",
"integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==",
"dev": true
}
"dezalgo": "^1.0.4",
"hexoid": "^1.0.0",
"once": "^1.4.0",
"qs": "^6.11.0"
}
},
"fromentries": {
@@ -6050,9 +6034,9 @@
"dev": true
},
"json5": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
"integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true
},
"kind-of": {
@@ -6931,20 +6915,23 @@
"dev": true
},
"socket.io-adapter": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.4.0.tgz",
"integrity": "sha512-W4N+o69rkMEGVuk2D/cvca3uYsvGlMwsySWV447y99gUPghxq42BxqLNMndb+a1mm/5/7NeXVQS7RLa2XyXvYg=="
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.2.tgz",
"integrity": "sha512-87C3LO/NOMc+eMcpcxUBebGjkpMDkNBS9tf7KJqcDsmL936EChtVva71Dw2q4tQcuVC+hAUy4an2NO/sYXmwRA==",
"requires": {
"ws": "~8.11.0"
}
},
"socket.io-client": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.5.4.tgz",
"integrity": "sha512-ZpKteoA06RzkD32IbqILZ+Cnst4xewU7ZYK12aS1mzHftFFjpoMz69IuhP/nL25pJfao/amoPI527KnuhFm01g==",
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.1.tgz",
"integrity": "sha512-Qk3Xj8ekbnzKu3faejo4wk2MzXA029XppiXtTF/PkbTg+fcwaTw1PlDrTrrrU4mKoYC4dvlApOnSeyLCKwek2w==",
"dev": true,
"requires": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.2.3",
"socket.io-parser": "~4.2.1"
"engine.io-client": "~6.5.1",
"socket.io-parser": "~4.2.4"
}
},
"socket.io-client-v2": {
@@ -7040,9 +7027,9 @@
}
},
"socket.io-parser": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.1.tgz",
"integrity": "sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"requires": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
@@ -7416,9 +7403,9 @@
"dev": true
},
"uWebSockets.js": {
"version": "git+https://git@github.com/uNetworking/uWebSockets.js.git#4558ee00f9f1f686fffe1accbfc2e85b1af9c50f",
"version": "git+https://git@github.com/uNetworking/uWebSockets.js.git#d39d4181daf5b670d44cbc1b18f8c28c85fd4142",
"dev": true,
"from": "uWebSockets.js@github:uNetworking/uWebSockets.js#v20.0.0"
"from": "uWebSockets.js@github:uNetworking/uWebSockets.js#v20.30.0"
},
"v8-compile-cache-lib": {
"version": "3.0.1",
@@ -7492,9 +7479,9 @@
}
},
"ws": {
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
"integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==",
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
"integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
"requires": {}
},
"xmlhttprequest-ssl": {

View File

@@ -1,6 +1,6 @@
{
"name": "socket.io",
"version": "4.5.4",
"version": "4.7.1",
"description": "node.js realtime framework server",
"keywords": [
"realtime",
@@ -26,9 +26,9 @@
"type": "commonjs",
"main": "./dist/index.js",
"exports": {
"types": "./dist/index.d.ts",
"import": "./wrapper.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
"require": "./dist/index.js"
},
"types": "./dist/index.d.ts",
"license": "MIT",
@@ -48,10 +48,11 @@
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"engine.io": "~6.2.1",
"socket.io-adapter": "~2.4.0",
"socket.io-parser": "~4.2.1"
"engine.io": "~6.5.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"devDependencies": {
"@types/mocha": "^9.0.0",
@@ -60,14 +61,14 @@
"nyc": "^15.1.0",
"prettier": "^2.3.2",
"rimraf": "^3.0.2",
"socket.io-client": "4.5.4",
"socket.io-client": "4.7.1",
"socket.io-client-v2": "npm:socket.io-client@^2.4.0",
"superagent": "^8.0.0",
"supertest": "^6.1.6",
"ts-node": "^10.2.1",
"tsd": "^0.21.0",
"typescript": "^4.4.2",
"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.0.0"
"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.30.0"
},
"contributors": [
{

View File

@@ -4,46 +4,13 @@ import { join } from "path";
import { exec } from "child_process";
import { Server } from "..";
import expect from "expect.js";
import { createClient, getPort } from "./support/util";
import request from "supertest";
// TODO: update superagent as latest release now supports promises
const eioHandshake = (httpServer): Promise<string> => {
return new Promise((resolve) => {
request(httpServer)
.get("/socket.io/")
.query({ transport: "polling", EIO: 4 })
.end((err, res) => {
const sid = JSON.parse(res.text.substring(1)).sid;
resolve(sid);
});
});
};
const eioPush = (httpServer, sid: string, body: string): Promise<void> => {
return new Promise((resolve) => {
request(httpServer)
.post("/socket.io/")
.send(body)
.query({ transport: "polling", EIO: 4, sid })
.expect(200)
.end(() => {
resolve();
});
});
};
const eioPoll = (httpServer, sid): Promise<string> => {
return new Promise((resolve) => {
request(httpServer)
.get("/socket.io/")
.query({ transport: "polling", EIO: 4, sid })
.expect(200)
.end((err, res) => {
resolve(res.text);
});
});
};
import {
createClient,
eioHandshake,
eioPoll,
eioPush,
getPort,
} from "./support/util";
describe("close", () => {
it("should be able to close sio sending a srv", (done) => {

View File

@@ -0,0 +1,247 @@
import { Server, Socket } from "..";
import expect from "expect.js";
import { waitFor, eioHandshake, eioPush, eioPoll } from "./support/util";
import { createServer, Server as HttpServer } from "http";
import { Adapter } from "socket.io-adapter";
async function init(httpServer: HttpServer, io: Server) {
// Engine.IO handshake
const sid = await eioHandshake(httpServer);
// Socket.IO handshake
await eioPush(httpServer, sid, "40");
const handshakeBody = await eioPoll(httpServer, sid);
expect(handshakeBody.startsWith("40")).to.be(true);
const handshake = JSON.parse(handshakeBody.substring(2));
expect(handshake.sid).to.not.be(undefined);
// in that case, the handshake also contains a private session ID
expect(handshake.pid).to.not.be(undefined);
io.emit("hello");
const message = await eioPoll(httpServer, sid);
expect(message.startsWith('42["hello"')).to.be(true);
const offset = JSON.parse(message.substring(2))[1];
// in that case, each packet also includes an offset in the data array
expect(offset).to.not.be(undefined);
await eioPush(httpServer, sid, "1");
return [handshake.sid, handshake.pid, offset];
}
describe("connection state recovery", () => {
it("should restore session and missed packets", async () => {
const httpServer = createServer().listen(0);
const io = new Server(httpServer, {
connectionStateRecovery: {},
});
let serverSocket;
io.once("connection", (socket) => {
socket.join("room1");
serverSocket = socket;
});
const [sid, pid, offset] = await init(httpServer, io);
io.emit("hello1"); // broadcast
io.to("room1").emit("hello2"); // broadcast to room
serverSocket.emit("hello3"); // direct message
const newSid = await eioHandshake(httpServer);
await eioPush(
httpServer,
newSid,
`40{"pid":"${pid}","offset":"${offset}"}`
);
const payload = await eioPoll(httpServer, newSid);
const packets = payload.split("\x1e");
expect(packets.length).to.eql(4);
// note: EVENT packets are received before the CONNECT packet, which is a bit weird
// see also: https://github.com/socketio/socket.io-deno/commit/518f534e1c205b746b1cb21fe76b187dabc96f34
expect(packets[0].startsWith('42["hello1"')).to.be(true);
expect(packets[1].startsWith('42["hello2"')).to.be(true);
expect(packets[2].startsWith('42["hello3"')).to.be(true);
expect(packets[3]).to.eql(`40{"sid":"${sid}","pid":"${pid}"}`);
io.close();
});
it("should restore rooms and data attributes", async () => {
const httpServer = createServer().listen(0);
const io = new Server(httpServer, {
connectionStateRecovery: {},
});
io.once("connection", (socket) => {
expect(socket.recovered).to.eql(false);
socket.join("room1");
socket.join("room2");
socket.data.foo = "bar";
});
const [sid, pid, offset] = await init(httpServer, io);
const newSid = await eioHandshake(httpServer);
const [socket] = await Promise.all([
waitFor<Socket>(io, "connection"),
eioPush(httpServer, newSid, `40{"pid":"${pid}","offset":"${offset}"}`),
]);
expect(socket.id).to.eql(sid);
expect(socket.recovered).to.eql(true);
expect(socket.rooms.has(socket.id)).to.eql(true);
expect(socket.rooms.has("room1")).to.eql(true);
expect(socket.rooms.has("room2")).to.eql(true);
expect(socket.data.foo).to.eql("bar");
await eioPoll(httpServer, newSid); // drain buffer
io.close();
});
it("should not run middlewares upon recovery by default", async () => {
const httpServer = createServer().listen(0);
const io = new Server(httpServer, {
connectionStateRecovery: {},
});
const [_, pid, offset] = await init(httpServer, io);
io.use((socket, next) => {
socket.data.middlewareWasCalled = true;
next();
});
const newSid = await eioHandshake(httpServer);
const [socket] = await Promise.all([
waitFor<Socket>(io, "connection"),
eioPush(httpServer, newSid, `40{"pid":"${pid}","offset":"${offset}"}`),
]);
expect(socket.recovered).to.be(true);
expect(socket.data.middlewareWasCalled).to.be(undefined);
await eioPoll(httpServer, newSid); // drain buffer
io.close();
});
it("should run middlewares even upon recovery", async () => {
const httpServer = createServer().listen(0);
const io = new Server(httpServer, {
connectionStateRecovery: {
skipMiddlewares: false,
},
});
const [_, pid, offset] = await init(httpServer, io);
io.use((socket, next) => {
socket.data.middlewareWasCalled = true;
next();
});
const newSid = await eioHandshake(httpServer);
const [socket] = await Promise.all([
waitFor<Socket>(io, "connection"),
eioPush(httpServer, newSid, `40{"pid":"${pid}","offset":"${offset}"}`),
]);
expect(socket.recovered).to.be(true);
expect(socket.data.middlewareWasCalled).to.be(true);
await eioPoll(httpServer, newSid); // drain buffer
io.close();
});
it("should fail to restore an unknown session", async () => {
const httpServer = createServer().listen(0);
const io = new Server(httpServer, {
connectionStateRecovery: {},
});
// Engine.IO handshake
const sid = await eioHandshake(httpServer);
// Socket.IO handshake
await eioPush(httpServer, sid, '40{"pid":"foo","offset":"bar"}');
const handshakeBody = await eioPoll(httpServer, sid);
expect(handshakeBody.startsWith("40")).to.be(true);
const handshake = JSON.parse(handshakeBody.substring(2));
expect(handshake.sid).to.not.eql("foo");
expect(handshake.pid).to.not.eql("bar");
io.close();
});
it("should be disabled by default", async () => {
const httpServer = createServer().listen(0);
const io = new Server(httpServer);
// Engine.IO handshake
const sid = await eioHandshake(httpServer);
// Socket.IO handshake
await eioPush(httpServer, sid, "40");
const handshakeBody = await eioPoll(httpServer, sid);
expect(handshakeBody.startsWith("40")).to.be(true);
const handshake = JSON.parse(handshakeBody.substring(2));
expect(handshake.sid).to.not.be(undefined);
expect(handshake.pid).to.be(undefined);
io.close();
});
it("should not call adapter#persistSession or adapter#restoreSession if disabled", async () => {
const httpServer = createServer().listen(0);
class DummyAdapter extends Adapter {
override persistSession(session) {
expect.fail();
}
override restoreSession(pid, offset) {
expect.fail();
return Promise.reject("should not happen");
}
}
const io = new Server(httpServer, {
adapter: DummyAdapter,
});
// Engine.IO handshake
const sid = await eioHandshake(httpServer);
await eioPush(httpServer, sid, '40{"pid":"foo","offset":"bar"}');
await eioPoll(httpServer, sid);
await eioPush(httpServer, sid, "1");
io.close();
});
});

View File

@@ -20,4 +20,5 @@ describe("socket.io", () => {
require("./socket-timeout");
require("./uws");
require("./utility-methods");
require("./connection-state-recovery");
});

View File

@@ -471,6 +471,74 @@ describe("messaging many", () => {
});
});
it("should broadcast and expect multiple acknowledgements (promise)", (done) => {
const io = new Server(0);
const socket1 = createClient(io, "/", { multiplex: false });
const socket2 = createClient(io, "/", { multiplex: false });
const socket3 = createClient(io, "/", { multiplex: false });
socket1.on("some event", (cb) => {
cb(1);
});
socket2.on("some event", (cb) => {
cb(2);
});
socket3.on("some event", (cb) => {
cb(3);
});
Promise.all([
waitFor(socket1, "connect"),
waitFor(socket2, "connect"),
waitFor(socket3, "connect"),
]).then(async () => {
const responses = await io.timeout(2000).emitWithAck("some event");
expect(responses).to.contain(1, 2, 3);
success(done, io, socket1, socket2, socket3);
});
});
it("should fail when a client does not acknowledge the event in the given delay (promise)", (done) => {
const io = new Server(0);
const socket1 = createClient(io, "/", { multiplex: false });
const socket2 = createClient(io, "/", { multiplex: false });
const socket3 = createClient(io, "/", { multiplex: false });
socket1.on("some event", (cb) => {
cb(1);
});
socket2.on("some event", (cb) => {
cb(2);
});
socket3.on("some event", () => {
// timeout
});
Promise.all([
waitFor(socket1, "connect"),
waitFor(socket2, "connect"),
waitFor(socket3, "connect"),
]).then(async () => {
try {
await io.timeout(200).emitWithAck("some event");
expect.fail();
} catch (err) {
expect(err).to.be.an(Error);
// @ts-ignore
expect(err.responses).to.have.length(2);
// @ts-ignore
expect(err.responses).to.contain(1, 2);
success(done, io, socket1, socket2, socket3);
}
});
});
it("should broadcast and return if the packet is sent to 0 client", (done) => {
const io = new Server(0);
const socket1 = createClient(io, "/", { multiplex: false });
@@ -498,4 +566,22 @@ describe("messaging many", () => {
success(done, io, socket1, socket2, socket3);
});
});
it("should precompute the WebSocket frame when broadcasting", (done) => {
const io = new Server(0);
const socket = createClient(io, "/chat", {
transports: ["websocket"],
});
const partialDone = createPartialDone(2, successFn(done, io, socket));
io.of("/chat").on("connection", (s) => {
s.conn.once("packetCreate", (packet) => {
expect(packet.options.wsPreEncodedFrame).to.be.an(Array);
partialDone();
});
io.of("/chat").compress(false).emit("woot", "hi");
});
socket.on("woot", partialDone);
});
});

View File

@@ -473,6 +473,24 @@ describe("namespaces", () => {
io.of("/nsp");
});
it("should not clean up a non-dynamic namespace", (done) => {
const io = new Server(0, { cleanupEmptyChildNamespaces: true });
const c1 = createClient(io, "/chat");
c1.on("connect", () => {
c1.disconnect();
// Give it some time to disconnect the client
setTimeout(() => {
expect(io._nsps.has("/chat")).to.be(true);
expect(io._nsps.get("/chat")!.sockets.size).to.be(0);
success(done, io);
}, 100);
});
io.of("/chat");
});
describe("dynamic namespaces", () => {
it("should allow connections to dynamic namespaces with a regex", (done) => {
const io = new Server(0);
@@ -571,5 +589,79 @@ describe("namespaces", () => {
one.on("message", handler);
two.on("message", handler);
});
it("should clean up namespace when cleanupEmptyChildNamespaces is on and there are no more sockets in a namespace", (done) => {
const io = new Server(0, { cleanupEmptyChildNamespaces: true });
const c1 = createClient(io, "/dynamic-101");
c1.on("connect", () => {
c1.disconnect();
// Give it some time to disconnect and clean up the namespace
setTimeout(() => {
expect(io._nsps.has("/dynamic-101")).to.be(false);
success(done, io);
}, 100);
});
io.of(/^\/dynamic-\d+$/);
});
it("should allow a client to connect to a cleaned up namespace", (done) => {
const io = new Server(0, { cleanupEmptyChildNamespaces: true });
const c1 = createClient(io, "/dynamic-101");
c1.on("connect", () => {
c1.disconnect();
// Give it some time to disconnect and clean up the namespace
setTimeout(() => {
expect(io._nsps.has("/dynamic-101")).to.be(false);
const c2 = createClient(io, "/dynamic-101");
c2.on("connect", () => {
success(done, io, c2);
});
c2.on("connect_error", () => {
done(
new Error("Client got error when connecting to dynamic namespace")
);
});
}, 100);
});
io.of(/^\/dynamic-\d+$/);
});
it("should not clean up namespace when cleanupEmptyChildNamespaces is off and there are no more sockets in a namespace", (done) => {
const io = new Server(0);
const c1 = createClient(io, "/dynamic-101");
c1.on("connect", () => {
c1.disconnect();
// Give it some time to disconnect and clean up the namespace
setTimeout(() => {
expect(io._nsps.has("/dynamic-101")).to.be(true);
expect(io._nsps.get("/dynamic-101")!.sockets.size).to.be(0);
success(done, io);
}, 100);
});
io.of(/^\/dynamic-\d+$/);
});
it("should attach a child namespace to its parent upon manual creation", () => {
const io = new Server(0);
const parentNamespace = io.of(/^\/dynamic-\d+$/);
const childNamespace = io.of("/dynamic-101");
// @ts-ignore
expect(parentNamespace.children.has(childNamespace)).to.be(true);
io.close();
});
});
});

View File

@@ -16,7 +16,9 @@ describe("server attachment", () => {
.buffer(true)
.end((err, res) => {
if (err) return done(err);
expect(res.headers["content-type"]).to.be("application/javascript");
expect(res.headers["content-type"]).to.be(
"application/javascript; charset=utf-8"
);
expect(res.headers.etag).to.be('"' + clientVersion + '"');
expect(res.headers["x-sourcemap"]).to.be(undefined);
expect(res.text).to.match(/engine\.io/);
@@ -33,7 +35,9 @@ describe("server attachment", () => {
.buffer(true)
.end((err, res) => {
if (err) return done(err);
expect(res.headers["content-type"]).to.be("application/json");
expect(res.headers["content-type"]).to.be(
"application/json; charset=utf-8"
);
expect(res.headers.etag).to.be('"' + clientVersion + '"');
expect(res.text).to.match(/engine\.io/);
expect(res.status).to.be(200);
@@ -66,6 +70,27 @@ describe("server attachment", () => {
});
});
it("should serve client with necessary CORS headers", (done) => {
const srv = createServer();
new Server(srv, {
cors: {
origin: "https://good-origin.com",
},
});
request(srv)
.get("/socket.io/socket.io.js")
.set("origin", "https://good-origin.com")
.buffer(true)
.end((err, res) => {
if (err) return done(err);
expect(res.headers["access-control-allow-origin"]).to.be(
"https://good-origin.com"
);
expect(res.status).to.be(200);
done();
});
});
it(
"should serve bundle with msgpack parser",
testSource("socket.io.msgpack.min.js")

View File

@@ -54,4 +54,34 @@ describe("timeout", () => {
});
});
});
it("should timeout if the client does not acknowledge the event (promise)", (done) => {
const io = new Server(0);
const client = createClient(io, "/");
io.on("connection", async (socket) => {
try {
await socket.timeout(50).emitWithAck("unknown");
expect.fail();
} catch (err) {
expect(err).to.be.an(Error);
success(done, io, client);
}
});
});
it("should not timeout if the client does acknowledge the event (promise)", (done) => {
const io = new Server(0);
const client = createClient(io, "/");
client.on("echo", (arg, cb) => {
cb(arg);
});
io.on("connection", async (socket) => {
const value = await socket.timeout(50).emitWithAck("echo", 42);
expect(value).to.be(42);
success(done, io, client);
});
});
});

View File

@@ -92,6 +92,28 @@ describe("server", () => {
});
});
});
describe("emitWithAck", () => {
it("accepts any parameters", () => {
const srv = createServer();
const sio = new Server(srv);
srv.listen(async () => {
const value = await sio
.timeout(1000)
.emitWithAck("ackFromServerSingleArg", true, "123");
expectType<any>(value);
sio.on("connection", async (s) => {
const value1 = await s.emitWithAck(
"ackFromServerSingleArg",
true,
"123"
);
expectType<any>(value1);
});
});
});
});
});
describe("single event map", () => {
@@ -167,10 +189,32 @@ describe("server", () => {
describe("listen and emit event maps", () => {
interface ClientToServerEvents {
helloFromClient: (message: string) => void;
ackFromClient: (
a: string,
b: number,
ack: (c: string, d: number) => void
) => void;
}
interface ServerToClientEvents {
helloFromServer: (message: string, x: number) => void;
ackFromServer: (
a: boolean,
b: string,
ack: (c: boolean, d: string) => void
) => void;
ackFromServerSingleArg: (
a: boolean,
b: string,
ack: (c: string) => void
) => void;
multipleAckFromServer: (
a: boolean,
b: string,
ack: (c: string) => void
) => void;
}
describe("on", () => {
@@ -185,6 +229,13 @@ describe("server", () => {
expectType<string>(message);
done();
});
s.on("ackFromClient", (a, b, cb) => {
expectType<string>(a);
expectType<number>(b);
expectType<(c: string, d: number) => void>(cb);
cb("123", 456);
});
});
});
});
@@ -213,8 +264,41 @@ describe("server", () => {
sio.to("room").emit("helloFromServer", "hi", 1);
sio.timeout(1000).emit("helloFromServer", "hi", 1);
sio
.timeout(1000)
.emit("multipleAckFromServer", true, "123", (err, c) => {
expectType<Error>(err);
expectType<string[]>(c);
});
sio.on("connection", (s) => {
s.emit("helloFromServer", "hi", 10);
s.emit("ackFromServer", true, "123", (c, d) => {
expectType<boolean>(c);
expectType<string>(d);
});
s.timeout(1000).emit("ackFromServer", true, "123", (err, c, d) => {
expectType<Error>(err);
expectType<boolean>(c);
expectType<string>(d);
});
s.timeout(1000)
.to("room")
.emit("multipleAckFromServer", true, "123", (err, c) => {
expectType<Error>(err);
expectType<string[]>(c);
});
s.to("room")
.timeout(1000)
.emit("multipleAckFromServer", true, "123", (err, c) => {
expectType<Error>(err);
expectType<string[]>(c);
});
done();
});
});
@@ -240,6 +324,42 @@ describe("server", () => {
});
});
});
describe("emitWithAck", () => {
it("accepts arguments of the correct types", (done) => {
const srv = createServer();
const sio = new Server<ClientToServerEvents, ServerToClientEvents>(srv);
srv.listen(async () => {
const value = await sio
.timeout(1000)
.emitWithAck("multipleAckFromServer", true, "123");
expectType<string[]>(value);
sio.on("connection", async (s) => {
const value1 = await s
.timeout(1000)
.to("room")
.emitWithAck("multipleAckFromServer", true, "123");
expectType<string[]>(value1);
const value2 = await s
.to("room")
.timeout(1000)
.emitWithAck("multipleAckFromServer", true, "123");
expectType<string[]>(value2);
const value3 = await s.emitWithAck(
"ackFromServerSingleArg",
true,
"123"
);
expectType<string>(value3);
done();
});
});
});
});
});
describe("listen and emit event maps for the serverSideEmit method", () => {
@@ -253,6 +373,7 @@ describe("server", () => {
interface InterServerEvents {
helloFromServerToServer: (message: string, x: number) => void;
ackFromServerToServer: (foo: string, cb: (bar: number) => void) => void;
}
describe("on", () => {
@@ -267,7 +388,7 @@ describe("server", () => {
expectType<
Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents>
>(sio);
srv.listen(() => {
srv.listen(async () => {
sio.serverSideEmit("helloFromServerToServer", "hello", 10);
sio
.of("/test")
@@ -281,6 +402,22 @@ describe("server", () => {
expectType<string>(message);
expectType<number>(x);
});
sio.serverSideEmit("ackFromServerToServer", "foo", (err, bar) => {
expectType<Error>(err);
expectType<number[]>(bar);
});
const value = await sio.serverSideEmitWithAck(
"ackFromServerToServer",
"foo"
);
expectType<number[]>(value);
sio.on("ackFromServerToServer", (foo, cb) => {
expectType<string>(foo);
expectType<(bar: number) => void>(cb);
});
});
});
});

View File

@@ -1,6 +1,12 @@
import fs = require("fs");
import { join } from "path";
import { createClient, getPort, success, successFn } from "./support/util";
import {
createClient,
createPartialDone,
getPort,
success,
successFn,
} from "./support/util";
import { Server } from "..";
import expect from "expect.js";
@@ -599,6 +605,24 @@ describe("socket", () => {
});
});
it("should emit an event and wait for the acknowledgement", (done) => {
const io = new Server(0);
const socket = createClient(io);
io.on("connection", async (s) => {
socket.on("hi", (a, b, fn) => {
expect(a).to.be(1);
expect(b).to.be(2);
fn(3);
});
const val = await s.emitWithAck("hi", 1, 2);
expect(val).to.be(3);
success(done, io, socket);
});
});
it("should have access to the client", (done) => {
const io = new Server(0);
const socket = createClient(io);
@@ -731,7 +755,7 @@ describe("socket", () => {
});
});
it("should enable compresion by default", (done) => {
it("should enable compression by default", (done) => {
const io = new Server(0);
const socket = createClient(io, "/chat");
@@ -740,11 +764,11 @@ describe("socket", () => {
expect(packet.options.compress).to.be(true);
success(done, io, socket);
});
io.of("/chat").emit("woot", "hi");
s.emit("woot", "hi");
});
});
it("should disable compresion", (done) => {
it("should disable compression", (done) => {
const io = new Server(0);
const socket = createClient(io, "/chat");
@@ -753,7 +777,7 @@ describe("socket", () => {
expect(packet.options.compress).to.be(false);
success(done, io, socket);
});
io.of("/chat").compress(false).emit("woot", "hi");
s.compress(false).emit("woot", "hi");
});
});
@@ -828,10 +852,6 @@ describe("socket", () => {
it("should not crash when messing with Object prototype (and other globals)", (done) => {
// @ts-ignore
Object.prototype.foo = "bar";
// @ts-ignore
global.File = "";
// @ts-ignore
global.Blob = [];
const io = new Server(0);
const socket = createClient(io);
@@ -995,6 +1015,22 @@ describe("socket", () => {
});
});
it("should call listener when broadcasting binary data", (done) => {
const io = new Server(0);
const clientSocket = createClient(io, "/", { multiplex: false });
io.on("connection", (socket) => {
socket.onAnyOutgoing((event, arg1) => {
expect(event).to.be("my-event");
expect(arg1).to.be.an(Uint8Array);
success(done, io, clientSocket);
});
io.emit("my-event", Uint8Array.of(1, 2, 3));
});
});
it("should prepend listener", (done) => {
const io = new Server(0);
const clientSocket = createClient(io, "/", { multiplex: false });
@@ -1039,5 +1075,30 @@ describe("socket", () => {
socket.emit("my-event", "123");
});
});
it("should disconnect all namespaces when calling disconnect(true)", (done) => {
const io = new Server(0);
io.of("/foo");
io.of("/bar");
const socket1 = createClient(io, "/", {
transports: ["websocket"],
});
const socket2 = createClient(io, "/foo");
const socket3 = createClient(io, "/bar");
io.of("/bar").on("connection", (socket) => {
socket.disconnect(true);
});
const partialDone = createPartialDone(
3,
successFn(done, io, socket1, socket2, socket3)
);
socket1.on("disconnect", partialDone);
socket2.on("disconnect", partialDone);
socket3.on("disconnect", partialDone);
});
});
});

View File

@@ -5,6 +5,7 @@ import {
Socket as ClientSocket,
SocketOptions,
} from "socket.io-client";
import request from "supertest";
const expect = require("expect.js");
const i = expect.stringify;
@@ -73,8 +74,46 @@ export function createPartialDone(count: number, done: (err?: Error) => void) {
};
}
export function waitFor(emitter, event) {
return new Promise((resolve) => {
export function waitFor<T = unknown>(emitter, event) {
return new Promise<T>((resolve) => {
emitter.once(event, resolve);
});
}
// TODO: update superagent as latest release now supports promises
export function eioHandshake(httpServer): Promise<string> {
return new Promise((resolve) => {
request(httpServer)
.get("/socket.io/")
.query({ transport: "polling", EIO: 4 })
.end((err, res) => {
const sid = JSON.parse(res.text.substring(1)).sid;
resolve(sid);
});
});
}
export function eioPush(httpServer, sid: string, body: string): Promise<void> {
return new Promise((resolve) => {
request(httpServer)
.post("/socket.io/")
.send(body)
.query({ transport: "polling", EIO: 4, sid })
.expect(200)
.end(() => {
resolve();
});
});
}
export function eioPoll(httpServer, sid): Promise<string> {
return new Promise((resolve) => {
request(httpServer)
.get("/socket.io/")
.query({ transport: "polling", EIO: 4, sid })
.expect(200)
.end((err, res) => {
resolve(res.text);
});
});
}

View File

@@ -53,7 +53,7 @@ describe("socket.io with uWebSocket.js-based engine", () => {
const partialDone = createPartialDone(done, 4);
client.on("connect", partialDone);
clientWSOnly.on("connect", partialDone);
clientWSOnly.once("connect", partialDone);
clientPollingOnly.on("connect", partialDone);
clientCustomNamespace.on("connect", partialDone);
});
@@ -200,7 +200,9 @@ describe("socket.io with uWebSocket.js-based engine", () => {
.buffer(true)
.end((err, res) => {
if (err) return done(err);
expect(res.headers["content-type"]).to.be("application/javascript");
expect(res.headers["content-type"]).to.be(
"application/javascript; charset=utf-8"
);
expect(res.headers.etag).to.be('"' + clientVersion + '"');
expect(res.headers["x-sourcemap"]).to.be(undefined);
expect(res.text).to.match(/engine\.io/);