mirror of
https://github.com/socketio/socket.io.git
synced 2026-01-09 15:08:12 -05:00
Merge remote-tracking branch 'engine.io-protocol/main'
Source: https://github.com/socketio/engine.io-protocol
This commit is contained in:
422
docs/engine.io-protocol/v4-current.md
Normal file
422
docs/engine.io-protocol/v4-current.md
Normal file
@@ -0,0 +1,422 @@
|
||||
# Engine.IO Protocol
|
||||
|
||||
This document describes the 4th version of the Engine.IO protocol.
|
||||
|
||||
**Table of content**
|
||||
|
||||
- [Introduction](#introduction)
|
||||
- [Transports](#transports)
|
||||
- [HTTP long-polling](#http-long-polling)
|
||||
- [Request path](#request-path)
|
||||
- [Query parameters](#query-parameters)
|
||||
- [Headers](#headers)
|
||||
- [Sending and receiving data](#sending-and-receiving-data)
|
||||
- [Sending data](#sending-data)
|
||||
- [Receiving data](#receiving-data)
|
||||
- [WebSocket](#websocket)
|
||||
- [Protocol](#protocol)
|
||||
- [Handshake](#handshake)
|
||||
- [Heartbeat](#heartbeat)
|
||||
- [Upgrade](#upgrade)
|
||||
- [Message](#message)
|
||||
- [Packet encoding](#packet-encoding)
|
||||
- [HTTP long-polling](#http-long-polling-1)
|
||||
- [WebSocket](#websocket-1)
|
||||
- [History](#history)
|
||||
- [From v2 to v3](#from-v2-to-v3)
|
||||
- [From v3 to v4](#from-v3-to-v4)
|
||||
- [Test suite](#test-suite)
|
||||
|
||||
|
||||
|
||||
## Introduction
|
||||
|
||||
The Engine.IO protocol enables [full-duplex](https://en.wikipedia.org/wiki/Duplex_(telecommunications)#FULL-DUPLEX) and low-overhead communication between a client and a server.
|
||||
|
||||
It is based on the [WebSocket protocol](https://en.wikipedia.org/wiki/WebSocket) and uses [HTTP long-polling](https://en.wikipedia.org/wiki/Push_technology#Long_polling) as fallback if the WebSocket connection can't be established.
|
||||
|
||||
The reference implementation is written in [TypeScript](https://www.typescriptlang.org/):
|
||||
|
||||
- server: https://github.com/socketio/engine.io
|
||||
- client: https://github.com/socketio/engine.io-client
|
||||
|
||||
The [Socket.IO protocol](https://github.com/socketio/socket.io-protocol) is built on top of these foundations, bringing additional features over the communication channel provided by the Engine.IO protocol.
|
||||
|
||||
## Transports
|
||||
|
||||
The connection between an Engine.IO client and an Engine.IO server can be established with:
|
||||
|
||||
- [HTTP long-polling](#http-long-polling)
|
||||
- [WebSocket](#websocket)
|
||||
|
||||
### HTTP long-polling
|
||||
|
||||
The HTTP long-polling transport (also simply referred as "polling") consists of successive HTTP requests:
|
||||
|
||||
- long-running `GET` requests, for receiving data from the server
|
||||
- short-running `POST` requests, for sending data to the server
|
||||
|
||||
#### Request path
|
||||
|
||||
The path of the HTTP requests is `/engine.io/` by default.
|
||||
|
||||
It might be updated by libraries built on top of the protocol (for example, the Socket.IO protocol uses `/socket.io/`).
|
||||
|
||||
#### Query parameters
|
||||
|
||||
The following query parameters are used:
|
||||
|
||||
| Name | Value | Description |
|
||||
|-------------|-----------|--------------------------------------------------------------------|
|
||||
| `EIO` | `4` | Mandatory, the version of the protocol. |
|
||||
| `transport` | `polling` | Mandatory, the name of the transport. |
|
||||
| `sid` | `<sid>` | Mandatory once the session is established, the session identifier. |
|
||||
|
||||
If a mandatory query parameter is missing, then the server MUST respond with an HTTP 400 error status.
|
||||
|
||||
#### Headers
|
||||
|
||||
When sending binary data, the sender (client or server) MUST include a `Content-Type: application/octet-stream` header.
|
||||
|
||||
Without an explicit `Content-Type` header, the receiver SHOULD infer that the data is plaintext.
|
||||
|
||||
Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type
|
||||
|
||||
#### Sending and receiving data
|
||||
|
||||
##### Sending data
|
||||
|
||||
To send some packets, a client MUST create an HTTP `POST` request with the packets encoded in the request body:
|
||||
|
||||
```
|
||||
CLIENT SERVER
|
||||
|
||||
│ │
|
||||
│ POST /engine.io/?EIO=4&transport=polling&sid=... │
|
||||
│ ───────────────────────────────────────────────────► │
|
||||
│ ◄──────────────────────────────────────────────────┘ │
|
||||
│ HTTP 200 │
|
||||
│ │
|
||||
```
|
||||
|
||||
The server MUST return an HTTP 400 response if the session ID (from the `sid` query parameter) is not known.
|
||||
|
||||
To indicate success, the server MUST return an HTTP 200 response, with the string `ok` in the response body.
|
||||
|
||||
To ensure packet ordering, a client MUST NOT have more than one active `POST` request. Should it happen, the server MUST return an HTTP 400 error status and close the session.
|
||||
|
||||
##### Receiving data
|
||||
|
||||
To receive some packets, a client MUST create an HTTP `GET` request:
|
||||
|
||||
```
|
||||
CLIENT SERVER
|
||||
|
||||
│ GET /engine.io/?EIO=4&transport=polling&sid=... │
|
||||
│ ──────────────────────────────────────────────────► │
|
||||
│ . │
|
||||
│ . │
|
||||
│ . │
|
||||
│ . │
|
||||
│ ◄─────────────────────────────────────────────────┘ │
|
||||
│ HTTP 200 │
|
||||
```
|
||||
|
||||
The server MUST return an HTTP 400 response if the session ID (from the `sid` query parameter) is not known.
|
||||
|
||||
The server MAY not respond right away if there are no packets buffered for the given session. Once there are some packets to be sent, the server SHOULD encode them (see [Packet encoding](#packet-encoding)) and send them in the response body of the HTTP request.
|
||||
|
||||
To ensure packet ordering, a client MUST NOT have more than one active `GET` request. Should it happen, the server MUST return an HTTP 400 error status and close the session.
|
||||
|
||||
### WebSocket
|
||||
|
||||
The WebSocket transport consists of a [WebSocket connection](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API), which provides a bidirectional and low-latency communication channel between the server and the client.
|
||||
|
||||
The following query parameters are used:
|
||||
|
||||
| Name | Value | Description |
|
||||
|-------------|-------------|-------------------------------------------------------------------------------|
|
||||
| `EIO` | `4` | Mandatory, the version of the protocol. |
|
||||
| `transport` | `websocket` | Mandatory, the name of the transport. |
|
||||
| `sid` | `<sid>` | Optional, depending on whether it's an upgrade from HTTP long-polling or not. |
|
||||
|
||||
If a mandatory query parameter is missing, then the server MUST close the WebSocket connection.
|
||||
|
||||
Each packet (read or write) is sent its own [WebSocket frame](https://datatracker.ietf.org/doc/html/rfc6455#section-5).
|
||||
|
||||
A client MUST NOT open more than one WebSocket connection per session. Should it happen, the server MUST close the WebSocket connection.
|
||||
|
||||
## Protocol
|
||||
|
||||
An Engine.IO packet consists of:
|
||||
|
||||
- a packet type
|
||||
- an optional packet payload
|
||||
|
||||
Here is the list of available packet types:
|
||||
|
||||
| Type | ID | Usage |
|
||||
|---------|-----|--------------------------------------------------|
|
||||
| open | 0 | Used during the [handshake](#handshake). |
|
||||
| close | 1 | Used to indicate that a transport can be closed. |
|
||||
| ping | 2 | Used in the [heartbeat mechanism](#heartbeat). |
|
||||
| pong | 3 | Used in the [heartbeat mechanism](#heartbeat). |
|
||||
| message | 4 | Used to send a payload to the other side. |
|
||||
| upgrade | 5 | Used during the [upgrade process](#upgrade). |
|
||||
| noop | 6 | Used during the [upgrade process](#upgrade). |
|
||||
|
||||
### Handshake
|
||||
|
||||
To establish a connection, the client MUST send an HTTP `GET` request to the server:
|
||||
|
||||
- HTTP long-polling first (by default)
|
||||
|
||||
```
|
||||
CLIENT SERVER
|
||||
|
||||
│ │
|
||||
│ GET /engine.io/?EIO=4&transport=polling │
|
||||
│ ───────────────────────────────────────────────────────► │
|
||||
│ ◄──────────────────────────────────────────────────────┘ │
|
||||
│ HTTP 200 │
|
||||
│ │
|
||||
```
|
||||
|
||||
- WebSocket-only session
|
||||
|
||||
```
|
||||
CLIENT SERVER
|
||||
|
||||
│ │
|
||||
│ GET /engine.io/?EIO=4&transport=websocket │
|
||||
│ ───────────────────────────────────────────────────────► │
|
||||
│ ◄──────────────────────────────────────────────────────┘ │
|
||||
│ HTTP 101 │
|
||||
│ │
|
||||
```
|
||||
|
||||
If the server accepts the connection, then it MUST respond with an `open` packet with the following JSON-encoded payload:
|
||||
|
||||
| Key | Type | Description |
|
||||
|----------------|------------|-------------------------------------------------------------------------------------------------------------------|
|
||||
| `sid` | `string` | The session ID. |
|
||||
| `upgrades` | `string[]` | The list of available [transport upgrades](#upgrade). |
|
||||
| `pingInterval` | `number` | The ping interval, used in the [heartbeat mechanism](#heartbeat) (in milliseconds). |
|
||||
| `pingTimeout` | `number` | The ping timeout, used in the [heartbeat mechanism](#heartbeat) (in milliseconds). |
|
||||
| `maxPayload` | `number` | The maximum number of bytes per chunk, used by the client to aggregate packets into [payloads](#packet-encoding). |
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"sid": "lv_VI97HAXpY6yYWAAAC",
|
||||
"upgrades": ["websocket"],
|
||||
"pingInterval": 25000,
|
||||
"pingTimeout": 20000,
|
||||
"maxPayload": 1000000
|
||||
}
|
||||
```
|
||||
|
||||
The client MUST send the `sid` value in the query parameters of all subsequent requests.
|
||||
|
||||
### Heartbeat
|
||||
|
||||
Once the [handshake](#handshake) is completed, a heartbeat mechanism is started to check the liveness of the connection:
|
||||
|
||||
```
|
||||
CLIENT SERVER
|
||||
|
||||
│ *** Handshake *** │
|
||||
│ │
|
||||
│ ◄───────────────────────────────────────────────── │
|
||||
│ 2 │ (ping packet)
|
||||
│ ─────────────────────────────────────────────────► │
|
||||
│ 3 │ (pong packet)
|
||||
```
|
||||
|
||||
At a given interval (the `pingInterval` value sent in the handshake) the server sends a `ping` packet and the client has a few seconds (the `pingTimeout` value) to send a `pong` packet back.
|
||||
|
||||
If the server does not receive a `pong` packet back, then it SHOULD consider that the connection is closed.
|
||||
|
||||
Conversely, if the client does not receive a `ping` packet within `pingInterval + pingTimeout`, then it SHOULD consider that the connection is closed.
|
||||
|
||||
### Upgrade
|
||||
|
||||
By default, the client SHOULD create an HTTP long-polling connection, and then upgrade to better transports if available.
|
||||
|
||||
To upgrade to WebSocket, the client MUST:
|
||||
|
||||
- pause the HTTP long-polling transport (no more HTTP request gets sent), to ensure that no packet gets lost
|
||||
- open a WebSocket connection with the same session ID
|
||||
- send a `ping` packet with the string `probe` in the payload
|
||||
|
||||
The server MUST:
|
||||
|
||||
- send a `noop` packet to any pending `GET` request (if applicable) to cleanly close HTTP long-polling transport
|
||||
- respond with a `pong` packet with the string `probe` in the payload
|
||||
|
||||
Finally, the client MUST send a `upgrade` packet to complete the upgrade:
|
||||
|
||||
```
|
||||
CLIENT SERVER
|
||||
|
||||
│ │
|
||||
│ GET /engine.io/?EIO=4&transport=websocket&sid=... │
|
||||
│ ───────────────────────────────────────────────────► │
|
||||
│ ◄─────────────────────────────────────────────────┘ │
|
||||
│ HTTP 101 (WebSocket handshake) │
|
||||
│ │
|
||||
│ ----- WebSocket frames ----- │
|
||||
│ ─────────────────────────────────────────────────► │
|
||||
│ 2probe │ (ping packet)
|
||||
│ ◄───────────────────────────────────────────────── │
|
||||
│ 3probe │ (pong packet)
|
||||
│ ─────────────────────────────────────────────────► │
|
||||
│ 5 │ (upgrade packet)
|
||||
│ │
|
||||
```
|
||||
|
||||
### Message
|
||||
|
||||
Once the [handshake](#handshake) is completed, the client and the server can exchange data by including it in a `message` packet.
|
||||
|
||||
|
||||
## Packet encoding
|
||||
|
||||
The serialization of an Engine.IO packet depends on the type of the payload (plaintext or binary) and on the transport.
|
||||
|
||||
The character encoding is UTF-8 for plain text and for base64-encoded binary payloads.
|
||||
|
||||
### HTTP long-polling
|
||||
|
||||
Due to the nature of the HTTP long-polling transport, multiple packets might be concatenated in a single payload in order to increase throughput.
|
||||
|
||||
Format:
|
||||
|
||||
```
|
||||
<packet type>[<data>]<separator><packet type>[<data>]<separator><packet type>[<data>][...]
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
4hello\x1e2\x1e4world
|
||||
|
||||
with:
|
||||
|
||||
4 => message packet type
|
||||
hello => message payload
|
||||
\x1e => separator
|
||||
2 => ping packet type
|
||||
\x1e => separator
|
||||
4 => message packet type
|
||||
world => message payload
|
||||
```
|
||||
|
||||
The packets are separated by the [record separator character](https://en.wikipedia.org/wiki/C0_and_C1_control_codes#Field_separators): `\x1e`
|
||||
|
||||
Binary payloads MUST be base64-encoded and prefixed with a `b` character:
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
4hello\x1ebAQIDBA==
|
||||
|
||||
with:
|
||||
|
||||
4 => message packet type
|
||||
hello => message payload
|
||||
\x1e => separator
|
||||
b => binary prefix
|
||||
AQIDBA== => buffer <01 02 03 04> encoded as base64
|
||||
```
|
||||
|
||||
The client SHOULD use the `maxPayload` value sent during the [handshake](#handshake) to decide how many packets should be concatenated.
|
||||
|
||||
### WebSocket
|
||||
|
||||
Each Engine.IO packet is sent in its own [WebSocket frame](https://datatracker.ietf.org/doc/html/rfc6455#section-5).
|
||||
|
||||
Format:
|
||||
|
||||
```
|
||||
<packet type>[<data>]
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
4hello
|
||||
|
||||
with:
|
||||
|
||||
4 => message packet type
|
||||
hello => message payload (UTF-8 encoded)
|
||||
```
|
||||
|
||||
Binary payloads are sent as is, without modification.
|
||||
|
||||
## History
|
||||
|
||||
### From v2 to v3
|
||||
|
||||
- add support for binary data
|
||||
|
||||
The [2nd version](https://github.com/socketio/engine.io-protocol/tree/v2) of the protocol is used in Socket.IO `v0.9` and below.
|
||||
|
||||
The [3rd version](https://github.com/socketio/engine.io-protocol/tree/v3) of the protocol is used in Socket.IO `v1` and `v2`.
|
||||
|
||||
### From v3 to v4
|
||||
|
||||
- reverse ping/pong mechanism
|
||||
|
||||
The ping packets are now sent by the server, because the timers set in the browsers are not reliable enough. We
|
||||
suspect that a lot of timeout problems came from timers being delayed on the client-side.
|
||||
|
||||
- always use base64 when encoding a payload with binary data
|
||||
|
||||
This change allows to treat all payloads (with or without binary) the same way, without having to take in account
|
||||
whether the client or the current transport supports binary data or not.
|
||||
|
||||
Please note that this only applies to HTTP long-polling. Binary data is sent in WebSocket frames with no additional transformation.
|
||||
|
||||
- use a record separator (`\x1e`) instead of counting of characters
|
||||
|
||||
Counting characters prevented (or at least makes harder) to implement the protocol in other languages, which may not use
|
||||
the UTF-16 encoding.
|
||||
|
||||
For example, `€` was encoded to `2:4€`, though `Buffer.byteLength('€') === 3`.
|
||||
|
||||
Note: this assumes the record separator is not used in the data.
|
||||
|
||||
The 4th version (current) is included in Socket.IO `v3` and above.
|
||||
|
||||
## Test suite
|
||||
|
||||
The test suite in the `test-suite/` directory lets you check the compliance of a server implementation.
|
||||
|
||||
Usage:
|
||||
|
||||
- in Node.js: `npm ci && npm test`
|
||||
- in a browser: simply open the `index.html` file in your browser
|
||||
|
||||
For reference, here is expected configuration for the JavaScript server to pass all tests:
|
||||
|
||||
```js
|
||||
import { listen } from "engine.io";
|
||||
|
||||
const server = listen(3000, {
|
||||
pingInterval: 300,
|
||||
pingTimeout: 200,
|
||||
maxPayload: 1e6,
|
||||
cors: {
|
||||
origin: "*"
|
||||
}
|
||||
});
|
||||
|
||||
server.on("connection", socket => {
|
||||
socket.on("data", (...args) => {
|
||||
socket.send(...args);
|
||||
});
|
||||
});
|
||||
```
|
||||
1
docs/engine.io-protocol/v4-test-suite/.gitignore
vendored
Executable file
1
docs/engine.io-protocol/v4-test-suite/.gitignore
vendored
Executable file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
30
docs/engine.io-protocol/v4-test-suite/index.html
Normal file
30
docs/engine.io-protocol/v4-test-suite/index.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<title>Test suite for the Engine.IO protocol</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/mocha@9/mocha.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="mocha"></div>
|
||||
|
||||
<script src="https://unpkg.com/mocha@9/mocha.js"></script>
|
||||
<script src="https://unpkg.com/chai@4/chai.js" ></script>
|
||||
<script src="https://unpkg.com/chai-string@1/chai-string.js" ></script>
|
||||
|
||||
<script class="mocha-init">
|
||||
mocha.setup("bdd");
|
||||
mocha.checkLeaks();
|
||||
</script>
|
||||
|
||||
<script type="module" src="test-suite.js"></script>
|
||||
|
||||
<script class="mocha-exec">
|
||||
mocha.run();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
10
docs/engine.io-protocol/v4-test-suite/node-imports.js
Normal file
10
docs/engine.io-protocol/v4-test-suite/node-imports.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import fetch from "node-fetch";
|
||||
import { WebSocket } from "ws";
|
||||
import chai from "chai";
|
||||
import chaiString from "chai-string";
|
||||
|
||||
chai.use(chaiString);
|
||||
|
||||
globalThis.fetch = fetch;
|
||||
globalThis.WebSocket = WebSocket;
|
||||
globalThis.chai = chai;
|
||||
1936
docs/engine.io-protocol/v4-test-suite/package-lock.json
generated
Normal file
1936
docs/engine.io-protocol/v4-test-suite/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
docs/engine.io-protocol/v4-test-suite/package.json
Normal file
18
docs/engine.io-protocol/v4-test-suite/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "engine.io-protocol-test-suite",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"format": "prettier -w *.js",
|
||||
"test": "mocha test-suite.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "^4.3.6",
|
||||
"chai-string": "^1.5.0",
|
||||
"mocha": "^9.2.1",
|
||||
"node-fetch": "^3.2.0",
|
||||
"prettier": "^2.5.1",
|
||||
"ws": "^8.5.0"
|
||||
}
|
||||
}
|
||||
569
docs/engine.io-protocol/v4-test-suite/test-suite.js
Normal file
569
docs/engine.io-protocol/v4-test-suite/test-suite.js
Normal file
@@ -0,0 +1,569 @@
|
||||
const isNodejs = typeof window === "undefined";
|
||||
|
||||
if (isNodejs) {
|
||||
// make the tests runnable in both the browser and Node.js
|
||||
await import("./node-imports.js");
|
||||
}
|
||||
|
||||
const { expect } = chai;
|
||||
|
||||
const URL = "http://localhost:3000";
|
||||
const WS_URL = URL.replace("http", "ws");
|
||||
|
||||
const PING_INTERVAL = 300;
|
||||
const PING_TIMEOUT = 200;
|
||||
|
||||
function sleep(delay) {
|
||||
return new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
function waitFor(socket, eventType) {
|
||||
return new Promise((resolve) => {
|
||||
socket.addEventListener(
|
||||
eventType,
|
||||
(event) => {
|
||||
resolve(event);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function initLongPollingSession() {
|
||||
const response = await fetch(`${URL}/engine.io/?EIO=4&transport=polling`);
|
||||
const content = await response.text();
|
||||
return JSON.parse(content.substring(1)).sid;
|
||||
}
|
||||
|
||||
describe("Engine.IO protocol", () => {
|
||||
describe("handshake", () => {
|
||||
describe("HTTP long-polling", () => {
|
||||
it("successfully opens a session", async () => {
|
||||
const response = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling`
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(200);
|
||||
|
||||
const content = await response.text();
|
||||
|
||||
expect(content).to.startsWith("0");
|
||||
|
||||
const value = JSON.parse(content.substring(1));
|
||||
|
||||
expect(value).to.have.all.keys(
|
||||
"sid",
|
||||
"upgrades",
|
||||
"pingInterval",
|
||||
"pingTimeout",
|
||||
"maxPayload"
|
||||
);
|
||||
expect(value.sid).to.be.a("string");
|
||||
expect(value.upgrades).to.eql(["websocket"]);
|
||||
expect(value.pingInterval).to.eql(PING_INTERVAL);
|
||||
expect(value.pingTimeout).to.eql(PING_TIMEOUT);
|
||||
expect(value.maxPayload).to.eql(1000000);
|
||||
});
|
||||
|
||||
it("fails with an invalid 'EIO' query parameter", async () => {
|
||||
const response = await fetch(`${URL}/engine.io/?transport=polling`);
|
||||
|
||||
expect(response.status).to.eql(400);
|
||||
|
||||
const response2 = await fetch(
|
||||
`${URL}/engine.io/?EIO=abc&transport=polling`
|
||||
);
|
||||
|
||||
expect(response2.status).to.eql(400);
|
||||
});
|
||||
|
||||
it("fails with an invalid 'transport' query parameter", async () => {
|
||||
const response = await fetch(`${URL}/engine.io/?EIO=4`);
|
||||
|
||||
expect(response.status).to.eql(400);
|
||||
|
||||
const response2 = await fetch(`${URL}/engine.io/?EIO=4&transport=abc`);
|
||||
|
||||
expect(response2.status).to.eql(400);
|
||||
});
|
||||
|
||||
it("fails with an invalid request method", async () => {
|
||||
const response = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling`,
|
||||
{
|
||||
method: "post",
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(400);
|
||||
|
||||
const response2 = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling`,
|
||||
{
|
||||
method: "put",
|
||||
}
|
||||
);
|
||||
|
||||
expect(response2.status).to.eql(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket", () => {
|
||||
it("successfully opens a session", async () => {
|
||||
const socket = new WebSocket(
|
||||
`${WS_URL}/engine.io/?EIO=4&transport=websocket`
|
||||
);
|
||||
|
||||
const { data } = await waitFor(socket, "message");
|
||||
|
||||
expect(data).to.startsWith("0");
|
||||
|
||||
const value = JSON.parse(data.substring(1));
|
||||
|
||||
expect(value).to.have.all.keys(
|
||||
"sid",
|
||||
"upgrades",
|
||||
"pingInterval",
|
||||
"pingTimeout",
|
||||
"maxPayload"
|
||||
);
|
||||
expect(value.sid).to.be.a("string");
|
||||
expect(value.upgrades).to.eql([]);
|
||||
expect(value.pingInterval).to.eql(PING_INTERVAL);
|
||||
expect(value.pingTimeout).to.eql(PING_TIMEOUT);
|
||||
expect(value.maxPayload).to.eql(1000000);
|
||||
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("fails with an invalid 'EIO' query parameter", async () => {
|
||||
const socket = new WebSocket(
|
||||
`${WS_URL}/engine.io/?transport=websocket`
|
||||
);
|
||||
|
||||
if (isNodejs) {
|
||||
socket.on("error", () => {});
|
||||
}
|
||||
|
||||
waitFor(socket, "close");
|
||||
|
||||
const socket2 = new WebSocket(
|
||||
`${WS_URL}/engine.io/?EIO=abc&transport=websocket`
|
||||
);
|
||||
|
||||
if (isNodejs) {
|
||||
socket2.on("error", () => {});
|
||||
}
|
||||
|
||||
waitFor(socket2, "close");
|
||||
});
|
||||
|
||||
it("fails with an invalid 'transport' query parameter", async () => {
|
||||
const socket = new WebSocket(`${WS_URL}/engine.io/?EIO=4`);
|
||||
|
||||
if (isNodejs) {
|
||||
socket.on("error", () => {});
|
||||
}
|
||||
|
||||
waitFor(socket, "close");
|
||||
|
||||
const socket2 = new WebSocket(
|
||||
`${WS_URL}/engine.io/?EIO=4&transport=abc`
|
||||
);
|
||||
|
||||
if (isNodejs) {
|
||||
socket2.on("error", () => {});
|
||||
}
|
||||
|
||||
waitFor(socket2, "close");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("message", () => {
|
||||
describe("HTTP long-polling", () => {
|
||||
it("sends and receives a payload containing one plain text packet", async () => {
|
||||
const sid = await initLongPollingSession();
|
||||
|
||||
const pushResponse = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`,
|
||||
{
|
||||
method: "post",
|
||||
body: "4hello",
|
||||
}
|
||||
);
|
||||
|
||||
expect(pushResponse.status).to.eql(200);
|
||||
|
||||
const postContent = await pushResponse.text();
|
||||
|
||||
expect(postContent).to.eql("ok");
|
||||
|
||||
const pollResponse = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
|
||||
);
|
||||
|
||||
expect(pollResponse.status).to.eql(200);
|
||||
|
||||
const pollContent = await pollResponse.text();
|
||||
|
||||
expect(pollContent).to.eql("4hello");
|
||||
});
|
||||
|
||||
it("sends and receives a payload containing several plain text packets", async () => {
|
||||
const sid = await initLongPollingSession();
|
||||
|
||||
const pushResponse = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`,
|
||||
{
|
||||
method: "post",
|
||||
body: "4test1\x1e4test2\x1e4test3",
|
||||
}
|
||||
);
|
||||
|
||||
expect(pushResponse.status).to.eql(200);
|
||||
|
||||
const postContent = await pushResponse.text();
|
||||
|
||||
expect(postContent).to.eql("ok");
|
||||
|
||||
const pollResponse = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
|
||||
);
|
||||
|
||||
expect(pollResponse.status).to.eql(200);
|
||||
|
||||
const pollContent = await pollResponse.text();
|
||||
|
||||
expect(pollContent).to.eql("4test1\x1e4test2\x1e4test3");
|
||||
});
|
||||
|
||||
it("sends and receives a payload containing plain text and binary packets", async () => {
|
||||
const sid = await initLongPollingSession();
|
||||
|
||||
const pushResponse = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`,
|
||||
{
|
||||
method: "post",
|
||||
body: "4hello\x1ebAQIDBA==",
|
||||
}
|
||||
);
|
||||
|
||||
expect(pushResponse.status).to.eql(200);
|
||||
|
||||
const postContent = await pushResponse.text();
|
||||
|
||||
expect(postContent).to.eql("ok");
|
||||
|
||||
const pollResponse = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
|
||||
);
|
||||
|
||||
expect(pollResponse.status).to.eql(200);
|
||||
|
||||
const pollContent = await pollResponse.text();
|
||||
|
||||
expect(pollContent).to.eql("4hello\x1ebAQIDBA==");
|
||||
});
|
||||
|
||||
it("closes the session upon invalid packet format", async () => {
|
||||
const sid = await initLongPollingSession();
|
||||
|
||||
try {
|
||||
const pushResponse = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`,
|
||||
{
|
||||
method: "post",
|
||||
body: "abc",
|
||||
}
|
||||
);
|
||||
|
||||
expect(pushResponse.status).to.eql(400);
|
||||
} catch (e) {
|
||||
// node-fetch throws when the request is closed abnormally
|
||||
}
|
||||
|
||||
const pollResponse = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
|
||||
);
|
||||
|
||||
expect(pollResponse.status).to.eql(400);
|
||||
});
|
||||
|
||||
it("closes the session upon duplicate poll requests", async () => {
|
||||
const sid = await initLongPollingSession();
|
||||
|
||||
const pollResponses = await Promise.all([
|
||||
fetch(`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`),
|
||||
sleep(5).then(() => fetch(`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}&t=burst`)),
|
||||
]);
|
||||
|
||||
expect(pollResponses[0].status).to.eql(200);
|
||||
|
||||
const content = await pollResponses[0].text();
|
||||
|
||||
expect(content).to.eql("1");
|
||||
|
||||
// the Node.js implementation uses HTTP 500 (Internal Server Error), but HTTP 400 seems more suitable
|
||||
expect(pollResponses[1].status).to.be.oneOf([400, 500]);
|
||||
|
||||
const pollResponse = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
|
||||
);
|
||||
|
||||
expect(pollResponse.status).to.eql(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket", () => {
|
||||
it("sends and receives a plain text packet", async () => {
|
||||
const socket = new WebSocket(
|
||||
`${WS_URL}/engine.io/?EIO=4&transport=websocket`
|
||||
);
|
||||
|
||||
await waitFor(socket, "open");
|
||||
|
||||
await waitFor(socket, "message"); // handshake
|
||||
|
||||
socket.send("4hello");
|
||||
|
||||
const { data } = await waitFor(socket, "message");
|
||||
|
||||
expect(data).to.eql("4hello");
|
||||
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("sends and receives a binary packet", async () => {
|
||||
const socket = new WebSocket(
|
||||
`${WS_URL}/engine.io/?EIO=4&transport=websocket`
|
||||
);
|
||||
socket.binaryType = "arraybuffer";
|
||||
|
||||
await waitFor(socket, "message"); // handshake
|
||||
|
||||
socket.send(Uint8Array.from([1, 2, 3, 4]));
|
||||
|
||||
const { data } = await waitFor(socket, "message");
|
||||
|
||||
expect(data).to.eql(Uint8Array.from([1, 2, 3, 4]).buffer);
|
||||
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("closes the session upon invalid packet format", async () => {
|
||||
const socket = new WebSocket(
|
||||
`${WS_URL}/engine.io/?EIO=4&transport=websocket`
|
||||
);
|
||||
|
||||
await waitFor(socket, "message"); // handshake
|
||||
|
||||
socket.send("abc");
|
||||
|
||||
await waitFor(socket, "close");
|
||||
|
||||
socket.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("heartbeat", function () {
|
||||
this.timeout(5000);
|
||||
|
||||
describe("HTTP long-polling", () => {
|
||||
it("sends ping/pong packets", async () => {
|
||||
const sid = await initLongPollingSession();
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const pollResponse = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
|
||||
);
|
||||
|
||||
expect(pollResponse.status).to.eql(200);
|
||||
|
||||
const pollContent = await pollResponse.text();
|
||||
|
||||
expect(pollContent).to.eql("2");
|
||||
|
||||
const pushResponse = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`,
|
||||
{
|
||||
method: "post",
|
||||
body: "3",
|
||||
}
|
||||
);
|
||||
|
||||
expect(pushResponse.status).to.eql(200);
|
||||
}
|
||||
});
|
||||
|
||||
it("closes the session upon ping timeout", async () => {
|
||||
const sid = await initLongPollingSession();
|
||||
|
||||
await sleep(PING_INTERVAL + PING_TIMEOUT);
|
||||
|
||||
const pollResponse = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
|
||||
);
|
||||
|
||||
expect(pollResponse.status).to.eql(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket", () => {
|
||||
it("sends ping/pong packets", async () => {
|
||||
const socket = new WebSocket(
|
||||
`${WS_URL}/engine.io/?EIO=4&transport=websocket`
|
||||
);
|
||||
|
||||
await waitFor(socket, "message"); // handshake
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const { data } = await waitFor(socket, "message");
|
||||
|
||||
expect(data).to.eql("2");
|
||||
|
||||
socket.send("3");
|
||||
}
|
||||
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("closes the session upon ping timeout", async () => {
|
||||
const socket = new WebSocket(
|
||||
`${WS_URL}/engine.io/?EIO=4&transport=websocket`
|
||||
);
|
||||
|
||||
await waitFor(socket, "close"); // handshake
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("close", () => {
|
||||
describe("HTTP long-polling", () => {
|
||||
it("forcefully closes the session", async () => {
|
||||
const sid = await initLongPollingSession();
|
||||
|
||||
const [pollResponse] = await Promise.all([
|
||||
fetch(`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`),
|
||||
fetch(`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`, {
|
||||
method: "post",
|
||||
body: "1",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(pollResponse.status).to.eql(200);
|
||||
|
||||
const pullContent = await pollResponse.text();
|
||||
|
||||
expect(pullContent).to.eql("6");
|
||||
|
||||
const pollResponse2 = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
|
||||
);
|
||||
|
||||
expect(pollResponse2.status).to.eql(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket", () => {
|
||||
it("forcefully closes the session", async () => {
|
||||
const socket = new WebSocket(
|
||||
`${WS_URL}/engine.io/?EIO=4&transport=websocket`
|
||||
);
|
||||
|
||||
await waitFor(socket, "message"); // handshake
|
||||
|
||||
socket.send("1");
|
||||
|
||||
await waitFor(socket, "close");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("upgrade", () => {
|
||||
it("successfully upgrades from HTTP long-polling to WebSocket", async () => {
|
||||
const sid = await initLongPollingSession();
|
||||
|
||||
const socket = new WebSocket(
|
||||
`${WS_URL}/engine.io/?EIO=4&transport=websocket&sid=${sid}`
|
||||
);
|
||||
|
||||
await waitFor(socket, "open");
|
||||
|
||||
// send probe
|
||||
socket.send("2probe");
|
||||
|
||||
const probeResponse = await waitFor(socket, "message");
|
||||
|
||||
expect(probeResponse.data).to.eql("3probe");
|
||||
|
||||
const pollResponse = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
|
||||
);
|
||||
|
||||
expect(pollResponse.status).to.eql(200);
|
||||
|
||||
const pollContent = await pollResponse.text();
|
||||
|
||||
expect(pollContent).to.eql("6"); // "noop" packet to cleanly end the HTTP long-polling request
|
||||
|
||||
// complete upgrade
|
||||
socket.send("5");
|
||||
|
||||
socket.send("4hello");
|
||||
|
||||
const { data } = await waitFor(socket, "message");
|
||||
|
||||
expect(data).to.eql("4hello");
|
||||
});
|
||||
|
||||
it("ignores HTTP requests with same sid after upgrade", async () => {
|
||||
const sid = await initLongPollingSession();
|
||||
|
||||
const socket = new WebSocket(
|
||||
`${WS_URL}/engine.io/?EIO=4&transport=websocket&sid=${sid}`
|
||||
);
|
||||
|
||||
await waitFor(socket, "open");
|
||||
socket.send("2probe");
|
||||
socket.send("5");
|
||||
|
||||
const pollResponse = await fetch(
|
||||
`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
|
||||
);
|
||||
|
||||
expect(pollResponse.status).to.eql(400);
|
||||
|
||||
socket.send("4hello");
|
||||
|
||||
const { data } = await waitFor(socket, "message");
|
||||
|
||||
expect(data).to.eql("4hello");
|
||||
});
|
||||
|
||||
it("ignores WebSocket connection with same sid after upgrade", async () => {
|
||||
const sid = await initLongPollingSession();
|
||||
|
||||
const socket = new WebSocket(
|
||||
`${WS_URL}/engine.io/?EIO=4&transport=websocket&sid=${sid}`
|
||||
);
|
||||
|
||||
await waitFor(socket, "open");
|
||||
socket.send("2probe");
|
||||
socket.send("5");
|
||||
|
||||
const socket2 = new WebSocket(
|
||||
`${WS_URL}/engine.io/?EIO=4&transport=websocket&sid=${sid}`
|
||||
);
|
||||
|
||||
await waitFor(socket2, "close");
|
||||
|
||||
socket.send("4hello");
|
||||
|
||||
const { data } = await waitFor(socket, "message");
|
||||
|
||||
expect(data).to.eql("4hello");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user