Compare commits
13
Commits
52ccf88940
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f5a8698e3 | ||
|
|
3dba76cd4a | ||
|
|
fa7e908a08 | ||
|
|
9dc511f08b | ||
|
|
5e57920039 | ||
|
|
3eb4bfcd5d | ||
|
|
4c3b99edac | ||
|
|
74ed1535ed | ||
|
|
aedd5d1f3f | ||
|
|
d02a2c7d5a | ||
|
|
991993e0c2 | ||
|
|
1f1164ab69 | ||
|
|
0620df79d3 |
@@ -0,0 +1,208 @@
|
|||||||
|
# Chat Demo Server 设计说明
|
||||||
|
|
||||||
|
## 1. 文档范围
|
||||||
|
|
||||||
|
本文依据当前仓库中的代码和 SQL 脚本,记录聊天服务端现有结构、模块职责、接口和消息协议、数据模型及设计取舍。文中“当前行为”指代码已经实现的行为;代码中存在但可能导致运行失败或安全风险的部分会在“已知问题与建议”中单独说明,不将计划性改进描述为已有能力。
|
||||||
|
|
||||||
|
## 2. 项目概述
|
||||||
|
|
||||||
|
本项目是一个基于 Node.js 的单进程聊天服务端,提供 HTTP REST 接口和 WebSocket 实时连接。Express 和 WebSocket 共用同一个 HTTP Server,监听 3000 端口;MySQL 保存用户、联系人和聊天消息。
|
||||||
|
|
||||||
|
当前实现的主要能力:
|
||||||
|
|
||||||
|
- 用户注册、用户名密码登录,并在登录成功后签发 JWT。
|
||||||
|
- 使用 JWT 中间件保护用户信息、联系人和聊天记录查询接口。
|
||||||
|
- 查询联系人、每个联系人的最近一条消息,以及指定联系人的分页消息。
|
||||||
|
- WebSocket 连接通过 JWT 验证身份,维护进程内在线用户连接表,并广播在线列表。
|
||||||
|
- WebSocket 消息处理代码包含消息发送、投递和 ACK 转发逻辑。
|
||||||
|
|
||||||
|
## 3. 技术栈与依赖
|
||||||
|
|
||||||
|
| 技术/依赖 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| Node.js | JavaScript 服务端运行环境 |
|
||||||
|
| Express 5 | HTTP 服务、JSON 请求体解析和路由分发 |
|
||||||
|
| `http` | 创建与 Express 共用的 HTTP Server |
|
||||||
|
| `ws` | WebSocket 服务端实现 |
|
||||||
|
| `mysql2` | MySQL 单连接及参数化 SQL 查询 |
|
||||||
|
| `jsonwebtoken` | JWT 签发和验证 |
|
||||||
|
| `dotenv` | 将 `.env` 配置加载到 `process.env` |
|
||||||
|
| `nodemon` | 开发时监视文件并重启服务 |
|
||||||
|
|
||||||
|
项目使用 CommonJS 模块格式。`npm start` 执行 `node index.js`,`npm run dev` 执行 `nodemon index.js`。当前 `npm test` 是占位脚本,没有自动化测试。
|
||||||
|
|
||||||
|
## 4. 代码结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
index.js 服务启动入口、HTTP 与 WebSocket 组装
|
||||||
|
db/
|
||||||
|
mysql.js MySQL 连接创建与导出
|
||||||
|
sql.txt users、contacts、messages 的建表及样例数据脚本
|
||||||
|
middleware/
|
||||||
|
authMiddleware.js HTTP 请求 JWT 校验
|
||||||
|
routes/
|
||||||
|
login.js 注册和登录接口
|
||||||
|
user.js 用户信息与联系人接口
|
||||||
|
message.js 最近聊天、历史消息、REST 消息写入接口
|
||||||
|
utils/
|
||||||
|
jwt.js JWT 签发和验证封装
|
||||||
|
websocket/
|
||||||
|
index.js WebSocket 握手、协议分发和消息处理
|
||||||
|
connectionManager.js 进程内在线连接管理及广播
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模块职责与依赖
|
||||||
|
|
||||||
|
- `index.js`:加载环境变量,创建 Express 应用和 HTTP Server,启用 JSON 请求解析,注册 `/api` 路由并将同一个 Server 交给 WebSocket 初始化函数,最后监听 3000 端口。
|
||||||
|
- `db/mysql.js`:创建一个 `mysql2` 单连接,并作为共享模块导出,路由和 WebSocket 处理器直接调用 `db.query`。
|
||||||
|
- `routes/login.js`:处理公开的 `/login`、`/register`。登录成功后通过 JWT 工具生成 token。
|
||||||
|
- `routes/user.js`:依赖 `authMiddleware`,从 `req.user.username` 识别当前用户并提供用户信息与联系人查询。
|
||||||
|
- `routes/message.js`:历史记录及最近聊天查询依赖认证中间件;当前 REST `/sendMessage` 未挂载认证中间件。
|
||||||
|
- `middleware/authMiddleware.js`:从 `Authorization` 请求头拆出 token,调用 JWT 验证,将 payload 放入 `req.user` 后继续处理请求。
|
||||||
|
- `utils/jwt.js`:使用 `JWT_SECRET` 签发和验证 token,签发有效期为 10 分钟。
|
||||||
|
- `websocket/index.js`:从连接 URL 查询参数读取 token 验证客户端身份;连接建立或关闭时广播在线用户列表;按消息 `type` 分发 WebSocket 消息。
|
||||||
|
- `websocket/connectionManager.js`:使用模块级 `Map` 以用户名为键保存 WebSocket 连接。该状态仅存在于当前 Node 进程内。
|
||||||
|
- `db/sql.txt`:描述用户、联系人、消息表,以及本地演示用数据。
|
||||||
|
|
||||||
|
## 5. 总体架构与请求路径
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Client[客户端]
|
||||||
|
Entry[index.js / HTTP Server]
|
||||||
|
Express[Express 路由]
|
||||||
|
Auth[JWT 中间件与工具]
|
||||||
|
WS[WebSocket 服务]
|
||||||
|
Manager[在线连接 Map]
|
||||||
|
DB[(MySQL)]
|
||||||
|
|
||||||
|
Client -->|HTTP /api| Entry
|
||||||
|
Entry --> Express
|
||||||
|
Express --> Auth
|
||||||
|
Express --> DB
|
||||||
|
Client <-->|WebSocket| Entry
|
||||||
|
Entry --> WS
|
||||||
|
WS --> Auth
|
||||||
|
WS --> Manager
|
||||||
|
WS --> DB
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 请求由 Express 根据 `/api` 前缀分发。需要认证的路由先运行 JWT 中间件,随后执行业务 SQL。WebSocket 则在握手后从 URL 查询参数读取 token,再在连接生命周期内以已验证的用户名处理消息。HTTP 与 WebSocket 最终都使用同一 MySQL 模块。
|
||||||
|
|
||||||
|
## 6. 数据模型
|
||||||
|
|
||||||
|
数据库脚本目标库为 `chat_mysql`,定义以下三张表:
|
||||||
|
|
||||||
|
### `users`
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束/用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `username` | `VARCHAR(50)` | 主键,用户唯一标识 |
|
||||||
|
| `password` | `VARCHAR(255)` | 非空;当前代码保存并直接比较明文密码 |
|
||||||
|
|
||||||
|
### `contacts`
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束/用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `user_name` | `VARCHAR(50)` | 联系人所属用户,外键引用 `users.username` |
|
||||||
|
| `contact_name` | `VARCHAR(50)` | 联系人用户名,外键引用 `users.username` |
|
||||||
|
|
||||||
|
`(user_name, contact_name)` 为联合主键。同一用户不能重复添加同一联系人。脚本中的联系人关系按有向关系保存,双向关系需要分别插入两行。
|
||||||
|
|
||||||
|
### `messages`
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束/用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | `INT` | 自增主键 |
|
||||||
|
| `sender_name` | `VARCHAR(50)` | 非空,外键引用发送者 |
|
||||||
|
| `receiver_name` | `VARCHAR(50)` | 非空,外键引用接收者 |
|
||||||
|
| `content` | `VARCHAR(1000)` | 非空,消息正文 |
|
||||||
|
| `created_at` | `DATETIME` | 默认使用数据库当前时间 |
|
||||||
|
|
||||||
|
消息以一条记录表示一次单向发送。双方聊天记录通过发送者和接收者字段组合查询;表中没有当前 WebSocket 处理器所写入的 `message_id` 字段。
|
||||||
|
|
||||||
|
## 7. HTTP 接口设计
|
||||||
|
|
||||||
|
所有路由统一挂载在 `/api` 下,JSON 请求体由 Express 解析。成功/失败响应大多采用 `{ code, message, data }` 结构,但不同接口的 HTTP 状态码与业务 `code` 使用并不完全一致。
|
||||||
|
|
||||||
|
| 方法 | 路径 | 认证 | 输入/用途 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `POST` | `/api/register` | 否 | body:`username`、`password`;插入用户 |
|
||||||
|
| `POST` | `/api/login` | 否 | body:`username`、`password`;验证凭据并返回 token、用户名 |
|
||||||
|
| `GET` | `/api/userInfo` | 是 | 从 token 返回当前用户名 |
|
||||||
|
| `GET` | `/api/contactsInfo` | 是 | 查询当前用户联系人,返回 `contacts` 数组 |
|
||||||
|
| `GET` | `/api/recentChats` | 是 | 查询当前用户每个聊天对象的最新消息 |
|
||||||
|
| `POST` | `/api/contactMessagesInfo` | 是 | body:`contactName`、`limit`、`offset`;分页查询双方消息 |
|
||||||
|
| `POST` | `/api/sendMessage` | **否** | body:`senderName`、`receiverName`、`content`;将消息插入数据库 |
|
||||||
|
|
||||||
|
### 认证约定
|
||||||
|
|
||||||
|
HTTP 客户端应在受保护接口的请求头中传入 `Authorization: Bearer <token>`。登录接口响应的 token 字符串自身已带 `Bearer ` 前缀。当前中间件通过空格拆分请求头并取第二段作为 token;缺少请求头或验证失败时返回 401。
|
||||||
|
|
||||||
|
JWT payload 当前包含 `username`,过期时间为 10 分钟。WebSocket 客户端把 token 放在连接 URL 查询参数 `token` 中。由于登录响应包含 `Bearer ` 前缀,而 WebSocket 端会直接将查询参数交给 `verifyToken`,客户端需要传纯 JWT 部分,不能把 `Bearer ` 前缀一并作为 token 值。
|
||||||
|
|
||||||
|
## 8. WebSocket 协议与消息流程
|
||||||
|
|
||||||
|
WebSocket 挂载在 HTTP Server 上,客户端连接地址的形式为 `ws://<host>:3000/?token=<JWT>`。连接验证通过后,服务端按用户名登记连接,并广播在线列表。
|
||||||
|
|
||||||
|
### 服务端消息类型
|
||||||
|
|
||||||
|
| `type` | 方向 | 当前代码行为 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `userOnline` | 服务端 -> 所有在线客户端 | 连接建立或关闭后,携带 `userList` 在线用户名数组 |
|
||||||
|
| `newMessage` | 服务端 -> 接收方 | 携带 `message` 对象,包括消息 ID、发送方、接收方和正文 |
|
||||||
|
| `sendSuccess` | 服务端 -> 发送方 | 携带客户端提供的 `messageId`,表示发送成功 |
|
||||||
|
| `sendError` | 服务端 -> 发送方 | 数据库写入回调报错时尝试发送,携带 `messageId` 和错误说明 |
|
||||||
|
| `ack` | 服务端 -> 原发送方 | 将接收方确认的消息 ID 转发给发送方 |
|
||||||
|
|
||||||
|
### 客户端消息类型
|
||||||
|
|
||||||
|
- `sendMessage`:处理器读取 `messageId`、`receiverName`、`content`;发送者身份来自已验证 WebSocket 连接,不从消息体读取。
|
||||||
|
- `ack`:处理器预期使用 `messageId` 和 `senderName` 将 ACK 转发给原发送者。
|
||||||
|
|
||||||
|
### 预期发送流程
|
||||||
|
|
||||||
|
1. 客户端建立携带 JWT 的 WebSocket 连接。
|
||||||
|
2. 服务端校验 JWT,登记用户名与连接,并广播在线用户列表。
|
||||||
|
3. 客户端发送 `sendMessage`,服务端使用连接身份确定发送者。
|
||||||
|
4. 服务端尝试把消息持久化到 MySQL;接收方在线时向其推送 `newMessage`。
|
||||||
|
5. 接收方返回 `ack` 后,服务端尝试通知原发送方。
|
||||||
|
|
||||||
|
步骤 4 当前实现没有等待数据库回调成功再确认和推送;此外建表脚本与插入语句字段不匹配,因此此流程目前不能视为可靠送达流程。具体差异见第 10 节。
|
||||||
|
|
||||||
|
## 9. 关键设计思路
|
||||||
|
|
||||||
|
- **按职责拆分模块**:入口负责组装,路由负责 HTTP 接口,middleware 负责认证,utils 封装 JWT,websocket 目录处理长连接和连接表,db 统一导出数据库连接。
|
||||||
|
- **用户名作为用户标识**:用户表以用户名为主键,联系人关系、消息发送接收双方及在线连接表均使用用户名关联。
|
||||||
|
- **JWT 无服务端会话存储**:服务端通过签名和过期时间验证 token,HTTP 请求和 WebSocket 握手各自执行验证。
|
||||||
|
- **聊天列表与历史消息分开查询**:`recentChats` 使用窗口函数按聊天对象分组并选出最新消息;历史消息接口按 limit/offset 分页,避免单次返回全部记录。
|
||||||
|
- **在线状态在内存维护**:当前使用 Map 直接由用户名定位 WebSocket 连接,便于单进程内向在线用户推送消息,但没有跨进程共享能力。
|
||||||
|
- **SQL 参数化**:业务查询使用 `?` 占位符与参数数组传值,避免把用户输入直接拼入 SQL 文本。
|
||||||
|
|
||||||
|
## 10. 已知问题与后续建议
|
||||||
|
|
||||||
|
以下内容是基于当前代码与脚本的直接核对结果,应在扩展功能或对外部署前处理:
|
||||||
|
|
||||||
|
1. **数据库凭据写在源码中**:`db/mysql.js` 将 MySQL 账号、密码和数据库名直接传给连接创建函数。建议改为读取环境变量,轮换已经放入源码的凭据,并避免将真实密钥写入文档或版本库。
|
||||||
|
2. **密码以明文存储和比对**:注册直接写入密码,登录 SQL 按用户名与明文密码匹配。应使用成熟密码哈希方案存储和校验,并考虑统一认证失败响应。
|
||||||
|
3. **REST 消息写入缺少认证**:`/api/sendMessage` 未使用 `authMiddleware`,客户端可自行指定发送者。应要求认证并从 `req.user` 派生发送者身份,同时校验接收方及联系人关系。(已解决)
|
||||||
|
4. **WebSocket 数据库写入字段不匹配**:`websocket/index.js` 插入 `message_id`,但 `db/sql.txt` 的 `messages` 表没有该列。需统一消息 ID 设计,例如新增唯一字段及迁移,或使用现有自增 `id` 并调整协议和查询。(已解决)
|
||||||
|
5. **发送确认早于持久化结果**:`sendSuccess` 和接收方推送发生在异步 `db.query` 回调之前。应将成功确认和实时推送放到成功回调中;失败时只返回错误,不推送为已发送消息。(已解决)
|
||||||
|
6. **连接管理器广播引用未定义的 `WebSocket`**:`connectionManager.js` 的 `broadcast` 检查 `WebSocket.OPEN`,但文件中没有导入 `ws`。当广播函数被调用时可能抛出 `ReferenceError`。应在此模块引入库常量,或由连接对象状态采用明确且可用的检查方式。(已解决)
|
||||||
|
7. **ACK 参数调用不一致**:消息分发通过 `handleAck(data)` 调用,而函数签名是 `handleAck(receiverName, data)`;函数内部随后读取第二个参数,可能因 `data` 未定义而失败。应统一函数参数并以当前连接用户名作为 ACK 接收者身份。(已解决)
|
||||||
|
8. **重复登录连接的关闭竞态**:同一用户名的新连接会覆盖 Map 中旧连接;旧连接关闭时无条件按用户名删除,可能把新连接也从 Map 移除。移除连接时应确认 Map 中仍是即将关闭的那个 WebSocket。(已解决)
|
||||||
|
9. **分页和输入校验不足**:`contactMessagesInfo` 未验证 `contactName`、`limit`、`offset` 的类型与范围,也未约束最大页大小。建议验证请求参数并设置默认值和上限。
|
||||||
|
10. **在线状态及可靠性受单进程限制**:Map 不支持多实例共享、服务重启恢复、离线消息投递或持久化 ACK 状态。若部署多实例,需要共享在线状态/消息协调机制;若要求可靠消息,应定义消息状态、幂等键、重试及离线投递策略。
|
||||||
|
11. **配置与错误响应尚未统一**:数据库连接配置没有从 `.env` 读取;HTTP 接口混用 HTTP 状态码和响应体 `code`,空结果有时返回业务码 401。建议集中配置并统一 API 错误语义及日志策略。
|
||||||
|
12. **SQL 初始化脚本需要整理验证**:脚本包含清空用户表的语句,执行前会删除该表全部用户数据。应将演示数据与建表迁移分离,并在测试数据库验证脚本。
|
||||||
|
13. **缺少自动化测试**:当前测试脚本是占位命令。建议优先为认证边界、参数校验、消息持久化失败/成功和 WebSocket 连接生命周期补充测试。
|
||||||
|
|
||||||
|
## 11. 本地运行与部署边界
|
||||||
|
|
||||||
|
1. 安装 Node.js 及 MySQL,并在 MySQL 中准备 `chat_mysql` 数据库。
|
||||||
|
2. 按需执行经过校验的建表脚本;注意当前 `sql.txt` 含删除数据语句,不应直接用于已有数据环境。
|
||||||
|
3. 设置 `JWT_SECRET`。当前 MySQL 参数仍需在 `db/mysql.js` 中配置;建议先将连接参数迁移到环境变量,再部署。
|
||||||
|
4. 执行 `npm install`,开发环境使用 `npm run dev`,普通启动使用 `npm start`。
|
||||||
|
5. 服务监听 `http://localhost:3000`,根路径 `/` 返回运行提示;WebSocket 使用相同主机和端口。
|
||||||
|
|
||||||
|
当前代码适用于本地演示和单进程验证,不具备生产环境所需的凭据管理、密码安全、输入校验、多实例在线状态和消息可靠性保障。
|
||||||
+11
-46
@@ -11,7 +11,8 @@ VALUES
|
|||||||
('query', '123'),
|
('query', '123'),
|
||||||
('tom', '123'),
|
('tom', '123'),
|
||||||
('jack', '123'),
|
('jack', '123'),
|
||||||
('lucy', '123');
|
('lucy', '123'),
|
||||||
|
('lily', '123');
|
||||||
select * from chat_mysql.users;
|
select * from chat_mysql.users;
|
||||||
|
|
||||||
|
|
||||||
@@ -36,11 +37,14 @@ VALUES
|
|||||||
('query', 'admin'),
|
('query', 'admin'),
|
||||||
('query', 'tom'),
|
('query', 'tom'),
|
||||||
('tom', 'admin'),
|
('tom', 'admin'),
|
||||||
('tom', 'query');
|
('tom', 'query'),
|
||||||
|
('admin', 'lily'),
|
||||||
|
('lily', 'admin');
|
||||||
select * from chat_mysql.contacts;
|
select * from chat_mysql.contacts;
|
||||||
|
|
||||||
CREATE TABLE chat_mysql.messages (
|
CREATE TABLE chat_mysql.messages (
|
||||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
message_id VARCHAR(64) NOT NULL,
|
||||||
sender_name VARCHAR(50) NOT NULL,
|
sender_name VARCHAR(50) NOT NULL,
|
||||||
receiver_name VARCHAR(50) NOT NULL,
|
receiver_name VARCHAR(50) NOT NULL,
|
||||||
content VARCHAR(1000) NOT NULL,
|
content VARCHAR(1000) NOT NULL,
|
||||||
@@ -52,48 +56,9 @@ CREATE TABLE chat_mysql.messages (
|
|||||||
FOREIGN KEY (receiver_name)
|
FOREIGN KEY (receiver_name)
|
||||||
REFERENCES chat_mysql.users(username)
|
REFERENCES chat_mysql.users(username)
|
||||||
);
|
);
|
||||||
INSERT INTO chat_mysql.messages
|
|
||||||
(sender_name, receiver_name, content, created_at)
|
|
||||||
VALUES
|
|
||||||
-- admin ↔ query
|
|
||||||
('admin', 'query', '你好,最近怎么样?', '2026-09-20 10:00:00'),
|
|
||||||
('query', 'admin', '挺好的,你呢?', '2026-09-20 10:01:00'),
|
|
||||||
('admin', 'query', '我也不错,最近在做聊天项目。', '2026-09-20 10:02:00'),
|
|
||||||
('query', 'admin', '听起来不错,是用 Vue 做的吗?', '2026-09-20 10:03:00'),
|
|
||||||
('admin', 'query', '对,前端使用 Vue2,后端使用 Node.js。', '2026-09-20 10:04:00'),
|
|
||||||
('query', 'admin', '那数据库使用 MySQL?', '2026-09-20 10:05:00'),
|
|
||||||
('admin', 'query', '对,目前正在完善聊天功能。', '2026-09-20 10:06:00'),
|
|
||||||
|
|
||||||
-- admin ↔ tom
|
#删除某个人的发送聊天数据
|
||||||
('admin', 'tom', '你今天有空吗?', '2026-09-20 11:00:00'),
|
SELECT *
|
||||||
('tom', 'admin', '有啊,怎么了?', '2026-09-20 11:01:00'),
|
FROM chat_mysql.messages
|
||||||
('admin', 'tom', '想和你讨论一下项目。', '2026-09-20 11:02:00'),
|
WHERE sender_name = 'test';
|
||||||
('tom', 'admin', '可以,什么时间方便?', '2026-09-20 11:03:00'),
|
DELETE FROM chat_mysql.messages WHERE sender_name = 'test';
|
||||||
('admin', 'tom', '下午三点怎么样?', '2026-09-20 11:04:00'),
|
|
||||||
('tom', 'admin', '没问题。', '2026-09-20 11:05:00'),
|
|
||||||
|
|
||||||
-- admin ↔ jack
|
|
||||||
('jack', 'admin', '周末一起吃饭吗?', '2026-09-20 12:00:00'),
|
|
||||||
('admin', 'jack', '可以啊,去哪吃?', '2026-09-20 12:01:00'),
|
|
||||||
('jack', 'admin', '附近找一家餐厅吧。', '2026-09-20 12:02:00'),
|
|
||||||
('admin', 'jack', '好的,晚上六点怎么样?', '2026-09-20 12:03:00'),
|
|
||||||
('jack', 'admin', '可以,到时候联系。', '2026-09-20 12:04:00'),
|
|
||||||
|
|
||||||
-- admin ↔ lucy
|
|
||||||
('admin', 'lucy', '最近项目进展怎么样?', '2026-09-20 13:00:00'),
|
|
||||||
('lucy', 'admin', '目前进展还不错。', '2026-09-20 13:01:00'),
|
|
||||||
('admin', 'lucy', '还有哪些功能没有完成?', '2026-09-20 13:02:00'),
|
|
||||||
('lucy', 'admin', '主要是消息通知和联系人功能。', '2026-09-20 13:03:00'),
|
|
||||||
('admin', 'lucy', '好的,我这边也在做消息功能。', '2026-09-20 13:04:00'),
|
|
||||||
|
|
||||||
-- query ↔ tom
|
|
||||||
('query', 'tom', '你知道聊天项目怎么做 WebSocket 吗?', '2026-09-20 14:00:00'),
|
|
||||||
('tom', 'query', '知道一些,可以用来做实时消息。', '2026-09-20 14:01:00'),
|
|
||||||
('query', 'tom', '我也准备把项目改成 WebSocket。', '2026-09-20 14:02:00'),
|
|
||||||
('tom', 'query', '这样聊天消息可以实时推送。', '2026-09-20 14:03:00'),
|
|
||||||
|
|
||||||
-- tom ↔ query
|
|
||||||
('tom', 'query', '联系人比较多的时候怎么办?', '2026-09-20 15:00:00'),
|
|
||||||
('query', 'tom', '不需要每个联系人都创建一个 WebSocket。', '2026-09-20 15:01:00'),
|
|
||||||
('tom', 'query', '一个用户维护一个 WebSocket 连接就可以了。', '2026-09-20 15:02:00');
|
|
||||||
select * from chat_mysql.messages;
|
|
||||||
|
|||||||
@@ -1,25 +1,42 @@
|
|||||||
|
console.log("当前执行文件:", __filename);
|
||||||
// .env 中的配置写入 Node.js 的全局环境变量对象,下面的文件都可以直接使用
|
// .env 中的配置写入 Node.js 的全局环境变量对象,下面的文件都可以直接使用
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
// 搭建http服务器
|
// 搭建http服务器
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
|
const http = require("http");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// 创建HTTP服务器
|
||||||
|
const server = http.createServer(app);
|
||||||
|
|
||||||
// 服务器解析 JSON 请求体
|
// 服务器解析 JSON 请求体
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
// 防止控制台打印404
|
||||||
|
app.get("/", (req, res) => {
|
||||||
|
res.send("chat-demo-server运行正常");
|
||||||
|
});
|
||||||
|
|
||||||
// 接入路由
|
// 接入路由
|
||||||
const loginRouter = require("./routes/login");
|
const loginRouter = require("./routes/login");
|
||||||
const userRouter = require("./routes/user");
|
const userRouter = require("./routes/user");
|
||||||
|
const messageRouter = require("./routes/message");
|
||||||
|
|
||||||
// 所有接口,都统一加上 /api 前缀
|
// 所有接口,都统一加上 /api 前缀
|
||||||
app.use("/api", loginRouter);
|
app.use("/api", loginRouter);
|
||||||
app.use("/api", userRouter);
|
app.use("/api", userRouter);
|
||||||
|
app.use("/api", messageRouter);
|
||||||
|
|
||||||
|
// 初始化WebSocket
|
||||||
|
const initWebSocket = require("./websocket/index.js");
|
||||||
|
initWebSocket(server);
|
||||||
|
|
||||||
// 启动服务器,监听端口
|
// 启动服务器,监听端口
|
||||||
app.listen(3000, () => {
|
server.listen(3000, () => {
|
||||||
console.log("服务器启动成功:http://localhost:3000");
|
console.log("服务器启动成功:http://localhost:3000");
|
||||||
|
console.log("WebSocket服务器启动成功:ws://localhost:3000");
|
||||||
});
|
});
|
||||||
|
|
||||||
// index.js 就是 Node 后端的“总入口”:加载配置 → 创建 Express → 解析请求 → 加载路由 → 注册接口 → 启动 3000 端口服务器
|
// index.js 就是 Node 后端的“总入口”:加载配置 → 创建 Express → 解析请求 → 加载路由 → 注册接口 → 启动 3000 端口服务器
|
||||||
+21
@@ -1448,6 +1448,27 @@
|
|||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
|
||||||
|
Copyright (c) 2013 Arnout Kazemier and contributors
|
||||||
|
Copyright (c) 2016 Luigi Pinca and contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||||
|
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
+548
@@ -0,0 +1,548 @@
|
|||||||
|
# ws: a Node.js WebSocket library
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/ws)
|
||||||
|
[](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster)
|
||||||
|
[](https://coveralls.io/github/websockets/ws)
|
||||||
|
|
||||||
|
ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and
|
||||||
|
server implementation.
|
||||||
|
|
||||||
|
Passes the quite extensive Autobahn test suite: [server][server-report],
|
||||||
|
[client][client-report].
|
||||||
|
|
||||||
|
**Note**: This module does not work in the browser. The client in the docs is a
|
||||||
|
reference to a backend with the role of a client in the WebSocket communication.
|
||||||
|
Browser clients must use the native
|
||||||
|
[`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
|
||||||
|
object. To make the same code work seamlessly on Node.js and the browser, you
|
||||||
|
can use one of the many wrappers available on npm, like
|
||||||
|
[isomorphic-ws](https://github.com/heineiuo/isomorphic-ws).
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Protocol support](#protocol-support)
|
||||||
|
- [Installing](#installing)
|
||||||
|
- [Opt-in for performance](#opt-in-for-performance)
|
||||||
|
- [Legacy opt-in for performance](#legacy-opt-in-for-performance)
|
||||||
|
- [API docs](#api-docs)
|
||||||
|
- [WebSocket compression](#websocket-compression)
|
||||||
|
- [Usage examples](#usage-examples)
|
||||||
|
- [Sending and receiving text data](#sending-and-receiving-text-data)
|
||||||
|
- [Sending binary data](#sending-binary-data)
|
||||||
|
- [Simple server](#simple-server)
|
||||||
|
- [External HTTP/S server](#external-https-server)
|
||||||
|
- [Multiple servers sharing a single HTTP/S server](#multiple-servers-sharing-a-single-https-server)
|
||||||
|
- [Client authentication](#client-authentication)
|
||||||
|
- [Server broadcast](#server-broadcast)
|
||||||
|
- [Round-trip time](#round-trip-time)
|
||||||
|
- [Use the Node.js streams API](#use-the-nodejs-streams-api)
|
||||||
|
- [Other examples](#other-examples)
|
||||||
|
- [FAQ](#faq)
|
||||||
|
- [How to get the IP address of the client?](#how-to-get-the-ip-address-of-the-client)
|
||||||
|
- [How to detect and close broken connections?](#how-to-detect-and-close-broken-connections)
|
||||||
|
- [How to connect via a proxy?](#how-to-connect-via-a-proxy)
|
||||||
|
- [Changelog](#changelog)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
|
## Protocol support
|
||||||
|
|
||||||
|
- **HyBi drafts 07-12** (Use the option `protocolVersion: 8`)
|
||||||
|
- **HyBi drafts 13-17** (Current default, alternatively option
|
||||||
|
`protocolVersion: 13`)
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install ws
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opt-in for performance
|
||||||
|
|
||||||
|
[bufferutil][] is an optional module that can be installed alongside the ws
|
||||||
|
module:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install --save-optional bufferutil
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a binary addon that improves the performance of certain operations such
|
||||||
|
as masking and unmasking the data payload of the WebSocket frames. Prebuilt
|
||||||
|
binaries are available for the most popular platforms, so you don't necessarily
|
||||||
|
need to have a C++ compiler installed on your machine.
|
||||||
|
|
||||||
|
To force ws to not use bufferutil, use the
|
||||||
|
[`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) environment variable. This
|
||||||
|
can be useful to enhance security in systems where a user can put a package in
|
||||||
|
the package search path of an application of another user, due to how the
|
||||||
|
Node.js resolver algorithm works.
|
||||||
|
|
||||||
|
#### Legacy opt-in for performance
|
||||||
|
|
||||||
|
If you are running on an old version of Node.js (prior to v18.14.0), ws also
|
||||||
|
supports the [utf-8-validate][] module:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install --save-optional utf-8-validate
|
||||||
|
```
|
||||||
|
|
||||||
|
This contains a binary polyfill for [`buffer.isUtf8()`][].
|
||||||
|
|
||||||
|
To force ws not to use utf-8-validate, use the
|
||||||
|
[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment variable.
|
||||||
|
|
||||||
|
## API docs
|
||||||
|
|
||||||
|
See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and
|
||||||
|
utility functions.
|
||||||
|
|
||||||
|
## WebSocket compression
|
||||||
|
|
||||||
|
ws supports the [permessage-deflate extension][permessage-deflate] which enables
|
||||||
|
the client and server to negotiate a compression algorithm and its parameters,
|
||||||
|
and then selectively apply it to the data payloads of each WebSocket message.
|
||||||
|
|
||||||
|
The extension is disabled by default on the server and enabled by default on the
|
||||||
|
client. It adds a significant overhead in terms of performance and memory
|
||||||
|
consumption so we suggest to enable it only if it is really needed.
|
||||||
|
|
||||||
|
Note that Node.js has a variety of issues with high-performance compression,
|
||||||
|
where increased concurrency, especially on Linux, can lead to [catastrophic
|
||||||
|
memory fragmentation][node-zlib-bug] and slow performance. If you intend to use
|
||||||
|
permessage-deflate in production, it is worthwhile to set up a test
|
||||||
|
representative of your workload and ensure Node.js/zlib will handle it with
|
||||||
|
acceptable performance and memory usage.
|
||||||
|
|
||||||
|
Tuning of permessage-deflate can be done via the options defined below. You can
|
||||||
|
also use `zlibDeflateOptions` and `zlibInflateOptions`, which is passed directly
|
||||||
|
into the creation of [raw deflate/inflate streams][node-zlib-deflaterawdocs].
|
||||||
|
|
||||||
|
See [the docs][ws-server-options] for more options.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket, { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({
|
||||||
|
port: 8080,
|
||||||
|
perMessageDeflate: {
|
||||||
|
zlibDeflateOptions: {
|
||||||
|
// See zlib defaults.
|
||||||
|
chunkSize: 1024,
|
||||||
|
memLevel: 7,
|
||||||
|
level: 3
|
||||||
|
},
|
||||||
|
zlibInflateOptions: {
|
||||||
|
chunkSize: 10 * 1024
|
||||||
|
},
|
||||||
|
// Other options settable:
|
||||||
|
clientNoContextTakeover: true, // Defaults to negotiated value.
|
||||||
|
serverNoContextTakeover: true, // Defaults to negotiated value.
|
||||||
|
serverMaxWindowBits: 10, // Defaults to negotiated value.
|
||||||
|
// Below options specified as default values.
|
||||||
|
concurrencyLimit: 10, // Limits zlib concurrency for perf.
|
||||||
|
threshold: 1024 // Size (in bytes) below which messages
|
||||||
|
// should not be compressed if context takeover is disabled.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The client will only use the extension if it is supported and enabled on the
|
||||||
|
server. To always disable the extension on the client, set the
|
||||||
|
`perMessageDeflate` option to `false`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
const ws = new WebSocket('ws://www.host.com/path', {
|
||||||
|
perMessageDeflate: false
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage examples
|
||||||
|
|
||||||
|
### Sending and receiving text data
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
const ws = new WebSocket('ws://www.host.com/path');
|
||||||
|
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('open', function open() {
|
||||||
|
ws.send('something');
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', function message(data) {
|
||||||
|
console.log('received: %s', data);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sending binary data
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
const ws = new WebSocket('ws://www.host.com/path');
|
||||||
|
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('open', function open() {
|
||||||
|
const array = new Float32Array(5);
|
||||||
|
|
||||||
|
for (var i = 0; i < array.length; ++i) {
|
||||||
|
array[i] = i / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(array);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Simple server
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ port: 8080 });
|
||||||
|
|
||||||
|
wss.on('connection', function connection(ws) {
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('message', function message(data) {
|
||||||
|
console.log('received: %s', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.send('something');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### External HTTP/S server
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { createServer } from 'https';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
const server = createServer({
|
||||||
|
cert: readFileSync('/path/to/cert.pem'),
|
||||||
|
key: readFileSync('/path/to/key.pem')
|
||||||
|
});
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
wss.on('connection', function connection(ws) {
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('message', function message(data) {
|
||||||
|
console.log('received: %s', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.send('something');
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(8080);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple servers sharing a single HTTP/S server
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
const server = createServer();
|
||||||
|
const wss1 = new WebSocketServer({ noServer: true });
|
||||||
|
const wss2 = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
wss1.on('connection', function connection(ws) {
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
wss2.on('connection', function connection(ws) {
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('upgrade', function upgrade(request, socket, head) {
|
||||||
|
const { pathname } = new URL(request.url, 'wss://base.url');
|
||||||
|
|
||||||
|
if (pathname === '/foo') {
|
||||||
|
wss1.handleUpgrade(request, socket, head, function done(ws) {
|
||||||
|
wss1.emit('connection', ws, request);
|
||||||
|
});
|
||||||
|
} else if (pathname === '/bar') {
|
||||||
|
wss2.handleUpgrade(request, socket, head, function done(ws) {
|
||||||
|
wss2.emit('connection', ws, request);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(8080);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client authentication
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
function onSocketError(err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer();
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
wss.on('connection', function connection(ws, request, client) {
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('message', function message(data) {
|
||||||
|
console.log(`Received message ${data} from user ${client}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('upgrade', function upgrade(request, socket, head) {
|
||||||
|
socket.on('error', onSocketError);
|
||||||
|
|
||||||
|
// This function is not defined on purpose. Implement it with your own logic.
|
||||||
|
authenticate(request, function next(err, client) {
|
||||||
|
if (err || !client) {
|
||||||
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.removeListener('error', onSocketError);
|
||||||
|
|
||||||
|
wss.handleUpgrade(request, socket, head, function done(ws) {
|
||||||
|
wss.emit('connection', ws, request, client);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(8080);
|
||||||
|
```
|
||||||
|
|
||||||
|
Also see the provided [example][session-parse-example] using `express-session`.
|
||||||
|
|
||||||
|
### Server broadcast
|
||||||
|
|
||||||
|
A client WebSocket broadcasting to all connected WebSocket clients, including
|
||||||
|
itself.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket, { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ port: 8080 });
|
||||||
|
|
||||||
|
wss.on('connection', function connection(ws) {
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('message', function message(data, isBinary) {
|
||||||
|
wss.clients.forEach(function each(client) {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(data, { binary: isBinary });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
A client WebSocket broadcasting to every other connected WebSocket clients,
|
||||||
|
excluding itself.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket, { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ port: 8080 });
|
||||||
|
|
||||||
|
wss.on('connection', function connection(ws) {
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('message', function message(data, isBinary) {
|
||||||
|
wss.clients.forEach(function each(client) {
|
||||||
|
if (client !== ws && client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(data, { binary: isBinary });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Round-trip time
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
const ws = new WebSocket('wss://websocket-echo.com/');
|
||||||
|
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('open', function open() {
|
||||||
|
console.log('connected');
|
||||||
|
ws.send(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', function close() {
|
||||||
|
console.log('disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', function message(data) {
|
||||||
|
console.log(`Round-trip time: ${Date.now() - data} ms`);
|
||||||
|
|
||||||
|
setTimeout(function timeout() {
|
||||||
|
ws.send(Date.now());
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use the Node.js streams API
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket, { createWebSocketStream } from 'ws';
|
||||||
|
|
||||||
|
const ws = new WebSocket('wss://websocket-echo.com/');
|
||||||
|
|
||||||
|
const duplex = createWebSocketStream(ws, { encoding: 'utf8' });
|
||||||
|
|
||||||
|
duplex.on('error', console.error);
|
||||||
|
|
||||||
|
duplex.pipe(process.stdout);
|
||||||
|
process.stdin.pipe(duplex);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other examples
|
||||||
|
|
||||||
|
For a full example with a browser client communicating with a ws server, see the
|
||||||
|
examples folder.
|
||||||
|
|
||||||
|
Otherwise, see the test cases.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
### How to get the IP address of the client?
|
||||||
|
|
||||||
|
The remote IP address can be obtained from the raw socket.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ port: 8080 });
|
||||||
|
|
||||||
|
wss.on('connection', function connection(ws, req) {
|
||||||
|
const ip = req.socket.remoteAddress;
|
||||||
|
|
||||||
|
ws.on('error', console.error);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
When the server runs behind a proxy like NGINX, the de-facto standard is to use
|
||||||
|
the `X-Forwarded-For` header.
|
||||||
|
|
||||||
|
```js
|
||||||
|
wss.on('connection', function connection(ws, req) {
|
||||||
|
const ip = req.headers['x-forwarded-for'].split(',')[0].trim();
|
||||||
|
|
||||||
|
ws.on('error', console.error);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to detect and close broken connections?
|
||||||
|
|
||||||
|
Sometimes, the link between the server and the client can be interrupted in a
|
||||||
|
way that keeps both the server and the client unaware of the broken state of the
|
||||||
|
connection (e.g. when pulling the cord).
|
||||||
|
|
||||||
|
In these cases, ping messages can be used as a means to verify that the remote
|
||||||
|
endpoint is still responsive.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
function heartbeat() {
|
||||||
|
this.isAlive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ port: 8080 });
|
||||||
|
|
||||||
|
wss.on('connection', function connection(ws) {
|
||||||
|
ws.isAlive = true;
|
||||||
|
ws.on('error', console.error);
|
||||||
|
ws.on('pong', heartbeat);
|
||||||
|
});
|
||||||
|
|
||||||
|
const interval = setInterval(function ping() {
|
||||||
|
wss.clients.forEach(function each(ws) {
|
||||||
|
if (ws.isAlive === false) return ws.terminate();
|
||||||
|
|
||||||
|
ws.isAlive = false;
|
||||||
|
ws.ping();
|
||||||
|
});
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
wss.on('close', function close() {
|
||||||
|
clearInterval(interval);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Pong messages are automatically sent in response to ping messages as required by
|
||||||
|
the spec.
|
||||||
|
|
||||||
|
Just like the server example above, your clients might as well lose connection
|
||||||
|
without knowing it. You might want to add a ping listener on your clients to
|
||||||
|
prevent that. A simple implementation would be:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
function heartbeat() {
|
||||||
|
clearTimeout(this.pingTimeout);
|
||||||
|
|
||||||
|
// Use `WebSocket#terminate()`, which immediately destroys the connection,
|
||||||
|
// instead of `WebSocket#close()`, which waits for the close timer.
|
||||||
|
// Delay should be equal to the interval at which your server
|
||||||
|
// sends out pings plus a conservative assumption of the latency.
|
||||||
|
this.pingTimeout = setTimeout(() => {
|
||||||
|
this.terminate();
|
||||||
|
}, 30000 + 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new WebSocket('wss://websocket-echo.com/');
|
||||||
|
|
||||||
|
client.on('error', console.error);
|
||||||
|
client.on('open', heartbeat);
|
||||||
|
client.on('ping', heartbeat);
|
||||||
|
client.on('close', function clear() {
|
||||||
|
clearTimeout(this.pingTimeout);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to connect via a proxy?
|
||||||
|
|
||||||
|
Use a custom `http.Agent` implementation like [https-proxy-agent][] or
|
||||||
|
[socks-proxy-agent][].
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
We're using the GitHub [releases][changelog] for changelog entries.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
|
|
||||||
|
[`buffer.isutf8()`]: https://nodejs.org/api/buffer.html#bufferisutf8input
|
||||||
|
[bufferutil]: https://github.com/websockets/bufferutil
|
||||||
|
[changelog]: https://github.com/websockets/ws/releases
|
||||||
|
[client-report]: http://websockets.github.io/ws/autobahn/clients/
|
||||||
|
[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent
|
||||||
|
[node-zlib-bug]: https://github.com/nodejs/node/issues/8871
|
||||||
|
[node-zlib-deflaterawdocs]:
|
||||||
|
https://nodejs.org/api/zlib.html#zlib_zlib_createdeflateraw_options
|
||||||
|
[permessage-deflate]: https://tools.ietf.org/html/rfc7692
|
||||||
|
[server-report]: http://websockets.github.io/ws/autobahn/servers/
|
||||||
|
[session-parse-example]: ./examples/express-session-parse
|
||||||
|
[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent
|
||||||
|
[utf-8-validate]: https://github.com/websockets/utf-8-validate
|
||||||
|
[ws-server-options]: ./doc/ws.md#new-websocketserveroptions-callback
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = function () {
|
||||||
|
throw new Error(
|
||||||
|
'ws does not work in the browser. Browser clients must use the native ' +
|
||||||
|
'WebSocket object'
|
||||||
|
);
|
||||||
|
};
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const createWebSocketStream = require('./lib/stream');
|
||||||
|
const extension = require('./lib/extension');
|
||||||
|
const PerMessageDeflate = require('./lib/permessage-deflate');
|
||||||
|
const Receiver = require('./lib/receiver');
|
||||||
|
const Sender = require('./lib/sender');
|
||||||
|
const subprotocol = require('./lib/subprotocol');
|
||||||
|
const WebSocket = require('./lib/websocket');
|
||||||
|
const WebSocketServer = require('./lib/websocket-server');
|
||||||
|
|
||||||
|
WebSocket.createWebSocketStream = createWebSocketStream;
|
||||||
|
WebSocket.extension = extension;
|
||||||
|
WebSocket.PerMessageDeflate = PerMessageDeflate;
|
||||||
|
WebSocket.Receiver = Receiver;
|
||||||
|
WebSocket.Sender = Sender;
|
||||||
|
WebSocket.Server = WebSocketServer;
|
||||||
|
WebSocket.subprotocol = subprotocol;
|
||||||
|
WebSocket.WebSocket = WebSocket;
|
||||||
|
WebSocket.WebSocketServer = WebSocketServer;
|
||||||
|
|
||||||
|
module.exports = WebSocket;
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { EMPTY_BUFFER } = require('./constants');
|
||||||
|
|
||||||
|
const FastBuffer = Buffer[Symbol.species];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges an array of buffers into a new buffer.
|
||||||
|
*
|
||||||
|
* @param {Buffer[]} list The array of buffers to concat
|
||||||
|
* @param {Number} totalLength The total length of buffers in the list
|
||||||
|
* @return {Buffer} The resulting buffer
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function concat(list, totalLength) {
|
||||||
|
if (list.length === 0) return EMPTY_BUFFER;
|
||||||
|
if (list.length === 1) return list[0];
|
||||||
|
|
||||||
|
const target = Buffer.allocUnsafe(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
const buf = list[i];
|
||||||
|
target.set(buf, offset);
|
||||||
|
offset += buf.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset < totalLength) {
|
||||||
|
return new FastBuffer(target.buffer, target.byteOffset, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Masks a buffer using the given mask.
|
||||||
|
*
|
||||||
|
* @param {Buffer} source The buffer to mask
|
||||||
|
* @param {Buffer} mask The mask to use
|
||||||
|
* @param {Buffer} output The buffer where to store the result
|
||||||
|
* @param {Number} offset The offset at which to start writing
|
||||||
|
* @param {Number} length The number of bytes to mask.
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function _mask(source, mask, output, offset, length) {
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
output[offset + i] = source[i] ^ mask[i & 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmasks a buffer using the given mask.
|
||||||
|
*
|
||||||
|
* @param {Buffer} buffer The buffer to unmask
|
||||||
|
* @param {Buffer} mask The mask to use
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function _unmask(buffer, mask) {
|
||||||
|
for (let i = 0; i < buffer.length; i++) {
|
||||||
|
buffer[i] ^= mask[i & 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a buffer to an `ArrayBuffer`.
|
||||||
|
*
|
||||||
|
* @param {Buffer} buf The buffer to convert
|
||||||
|
* @return {ArrayBuffer} Converted buffer
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function toArrayBuffer(buf) {
|
||||||
|
if (buf.length === buf.buffer.byteLength) {
|
||||||
|
return buf.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts `data` to a `Buffer`.
|
||||||
|
*
|
||||||
|
* @param {*} data The data to convert
|
||||||
|
* @return {Buffer} The buffer
|
||||||
|
* @throws {TypeError}
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function toBuffer(data) {
|
||||||
|
toBuffer.readOnly = true;
|
||||||
|
|
||||||
|
if (Buffer.isBuffer(data)) return data;
|
||||||
|
|
||||||
|
let buf;
|
||||||
|
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
buf = new FastBuffer(data);
|
||||||
|
} else if (ArrayBuffer.isView(data)) {
|
||||||
|
buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength);
|
||||||
|
} else {
|
||||||
|
buf = Buffer.from(data);
|
||||||
|
toBuffer.readOnly = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
concat,
|
||||||
|
mask: _mask,
|
||||||
|
toArrayBuffer,
|
||||||
|
toBuffer,
|
||||||
|
unmask: _unmask
|
||||||
|
};
|
||||||
|
|
||||||
|
/* istanbul ignore else */
|
||||||
|
if (!process.env.WS_NO_BUFFER_UTIL) {
|
||||||
|
try {
|
||||||
|
const bufferUtil = require('bufferutil');
|
||||||
|
|
||||||
|
module.exports.mask = function (source, mask, output, offset, length) {
|
||||||
|
if (length < 48) _mask(source, mask, output, offset, length);
|
||||||
|
else bufferUtil.mask(source, mask, output, offset, length);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.unmask = function (buffer, mask) {
|
||||||
|
if (buffer.length < 32) _unmask(buffer, mask);
|
||||||
|
else bufferUtil.unmask(buffer, mask);
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// Continue regardless of the error.
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BINARY_TYPES = ['nodebuffer', 'arraybuffer', 'fragments'];
|
||||||
|
const hasBlob = typeof Blob !== 'undefined';
|
||||||
|
|
||||||
|
if (hasBlob) BINARY_TYPES.push('blob');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
BINARY_TYPES,
|
||||||
|
CLOSE_TIMEOUT: 30000,
|
||||||
|
EMPTY_BUFFER: Buffer.alloc(0),
|
||||||
|
GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
|
||||||
|
hasBlob,
|
||||||
|
kForOnEventAttribute: Symbol('kIsForOnEventAttribute'),
|
||||||
|
kListener: Symbol('kListener'),
|
||||||
|
kStatusCode: Symbol('status-code'),
|
||||||
|
kWebSocket: Symbol('websocket'),
|
||||||
|
NOOP: () => {}
|
||||||
|
};
|
||||||
+292
@@ -0,0 +1,292 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { kForOnEventAttribute, kListener } = require('./constants');
|
||||||
|
|
||||||
|
const kCode = Symbol('kCode');
|
||||||
|
const kData = Symbol('kData');
|
||||||
|
const kError = Symbol('kError');
|
||||||
|
const kMessage = Symbol('kMessage');
|
||||||
|
const kReason = Symbol('kReason');
|
||||||
|
const kTarget = Symbol('kTarget');
|
||||||
|
const kType = Symbol('kType');
|
||||||
|
const kWasClean = Symbol('kWasClean');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class representing an event.
|
||||||
|
*/
|
||||||
|
class Event {
|
||||||
|
/**
|
||||||
|
* Create a new `Event`.
|
||||||
|
*
|
||||||
|
* @param {String} type The name of the event
|
||||||
|
* @throws {TypeError} If the `type` argument is not specified
|
||||||
|
*/
|
||||||
|
constructor(type) {
|
||||||
|
this[kTarget] = null;
|
||||||
|
this[kType] = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {*}
|
||||||
|
*/
|
||||||
|
get target() {
|
||||||
|
return this[kTarget];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {String}
|
||||||
|
*/
|
||||||
|
get type() {
|
||||||
|
return this[kType];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(Event.prototype, 'target', { enumerable: true });
|
||||||
|
Object.defineProperty(Event.prototype, 'type', { enumerable: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class representing a close event.
|
||||||
|
*
|
||||||
|
* @extends Event
|
||||||
|
*/
|
||||||
|
class CloseEvent extends Event {
|
||||||
|
/**
|
||||||
|
* Create a new `CloseEvent`.
|
||||||
|
*
|
||||||
|
* @param {String} type The name of the event
|
||||||
|
* @param {Object} [options] A dictionary object that allows for setting
|
||||||
|
* attributes via object members of the same name
|
||||||
|
* @param {Number} [options.code=0] The status code explaining why the
|
||||||
|
* connection was closed
|
||||||
|
* @param {String} [options.reason=''] A human-readable string explaining why
|
||||||
|
* the connection was closed
|
||||||
|
* @param {Boolean} [options.wasClean=false] Indicates whether or not the
|
||||||
|
* connection was cleanly closed
|
||||||
|
*/
|
||||||
|
constructor(type, options = {}) {
|
||||||
|
super(type);
|
||||||
|
|
||||||
|
this[kCode] = options.code === undefined ? 0 : options.code;
|
||||||
|
this[kReason] = options.reason === undefined ? '' : options.reason;
|
||||||
|
this[kWasClean] = options.wasClean === undefined ? false : options.wasClean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Number}
|
||||||
|
*/
|
||||||
|
get code() {
|
||||||
|
return this[kCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {String}
|
||||||
|
*/
|
||||||
|
get reason() {
|
||||||
|
return this[kReason];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Boolean}
|
||||||
|
*/
|
||||||
|
get wasClean() {
|
||||||
|
return this[kWasClean];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(CloseEvent.prototype, 'code', { enumerable: true });
|
||||||
|
Object.defineProperty(CloseEvent.prototype, 'reason', { enumerable: true });
|
||||||
|
Object.defineProperty(CloseEvent.prototype, 'wasClean', { enumerable: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class representing an error event.
|
||||||
|
*
|
||||||
|
* @extends Event
|
||||||
|
*/
|
||||||
|
class ErrorEvent extends Event {
|
||||||
|
/**
|
||||||
|
* Create a new `ErrorEvent`.
|
||||||
|
*
|
||||||
|
* @param {String} type The name of the event
|
||||||
|
* @param {Object} [options] A dictionary object that allows for setting
|
||||||
|
* attributes via object members of the same name
|
||||||
|
* @param {*} [options.error=null] The error that generated this event
|
||||||
|
* @param {String} [options.message=''] The error message
|
||||||
|
*/
|
||||||
|
constructor(type, options = {}) {
|
||||||
|
super(type);
|
||||||
|
|
||||||
|
this[kError] = options.error === undefined ? null : options.error;
|
||||||
|
this[kMessage] = options.message === undefined ? '' : options.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {*}
|
||||||
|
*/
|
||||||
|
get error() {
|
||||||
|
return this[kError];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {String}
|
||||||
|
*/
|
||||||
|
get message() {
|
||||||
|
return this[kMessage];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(ErrorEvent.prototype, 'error', { enumerable: true });
|
||||||
|
Object.defineProperty(ErrorEvent.prototype, 'message', { enumerable: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class representing a message event.
|
||||||
|
*
|
||||||
|
* @extends Event
|
||||||
|
*/
|
||||||
|
class MessageEvent extends Event {
|
||||||
|
/**
|
||||||
|
* Create a new `MessageEvent`.
|
||||||
|
*
|
||||||
|
* @param {String} type The name of the event
|
||||||
|
* @param {Object} [options] A dictionary object that allows for setting
|
||||||
|
* attributes via object members of the same name
|
||||||
|
* @param {*} [options.data=null] The message content
|
||||||
|
*/
|
||||||
|
constructor(type, options = {}) {
|
||||||
|
super(type);
|
||||||
|
|
||||||
|
this[kData] = options.data === undefined ? null : options.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {*}
|
||||||
|
*/
|
||||||
|
get data() {
|
||||||
|
return this[kData];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(MessageEvent.prototype, 'data', { enumerable: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This provides methods for emulating the `EventTarget` interface. It's not
|
||||||
|
* meant to be used directly.
|
||||||
|
*
|
||||||
|
* @mixin
|
||||||
|
*/
|
||||||
|
const EventTarget = {
|
||||||
|
/**
|
||||||
|
* Register an event listener.
|
||||||
|
*
|
||||||
|
* @param {String} type A string representing the event type to listen for
|
||||||
|
* @param {(Function|Object)} handler The listener to add
|
||||||
|
* @param {Object} [options] An options object specifies characteristics about
|
||||||
|
* the event listener
|
||||||
|
* @param {Boolean} [options.once=false] A `Boolean` indicating that the
|
||||||
|
* listener should be invoked at most once after being added. If `true`,
|
||||||
|
* the listener would be automatically removed when invoked.
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
addEventListener(type, handler, options = {}) {
|
||||||
|
for (const listener of this.listeners(type)) {
|
||||||
|
if (
|
||||||
|
!options[kForOnEventAttribute] &&
|
||||||
|
listener[kListener] === handler &&
|
||||||
|
!listener[kForOnEventAttribute]
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
if (type === 'message') {
|
||||||
|
wrapper = function onMessage(data, isBinary) {
|
||||||
|
const event = new MessageEvent('message', {
|
||||||
|
data: isBinary ? data : data.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
event[kTarget] = this;
|
||||||
|
callListener(handler, this, event);
|
||||||
|
};
|
||||||
|
} else if (type === 'close') {
|
||||||
|
wrapper = function onClose(code, message) {
|
||||||
|
const event = new CloseEvent('close', {
|
||||||
|
code,
|
||||||
|
reason: message.toString(),
|
||||||
|
wasClean: this._closeFrameReceived && this._closeFrameSent
|
||||||
|
});
|
||||||
|
|
||||||
|
event[kTarget] = this;
|
||||||
|
callListener(handler, this, event);
|
||||||
|
};
|
||||||
|
} else if (type === 'error') {
|
||||||
|
wrapper = function onError(error) {
|
||||||
|
const event = new ErrorEvent('error', {
|
||||||
|
error,
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
|
||||||
|
event[kTarget] = this;
|
||||||
|
callListener(handler, this, event);
|
||||||
|
};
|
||||||
|
} else if (type === 'open') {
|
||||||
|
wrapper = function onOpen() {
|
||||||
|
const event = new Event('open');
|
||||||
|
|
||||||
|
event[kTarget] = this;
|
||||||
|
callListener(handler, this, event);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute];
|
||||||
|
wrapper[kListener] = handler;
|
||||||
|
|
||||||
|
if (options.once) {
|
||||||
|
this.once(type, wrapper);
|
||||||
|
} else {
|
||||||
|
this.on(type, wrapper);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an event listener.
|
||||||
|
*
|
||||||
|
* @param {String} type A string representing the event type to remove
|
||||||
|
* @param {(Function|Object)} handler The listener to remove
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
removeEventListener(type, handler) {
|
||||||
|
for (const listener of this.listeners(type)) {
|
||||||
|
if (listener[kListener] === handler && !listener[kForOnEventAttribute]) {
|
||||||
|
this.removeListener(type, listener);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CloseEvent,
|
||||||
|
ErrorEvent,
|
||||||
|
Event,
|
||||||
|
EventTarget,
|
||||||
|
MessageEvent
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call an event listener
|
||||||
|
*
|
||||||
|
* @param {(Function|Object)} listener The listener to call
|
||||||
|
* @param {*} thisArg The value to use as `this`` when calling the listener
|
||||||
|
* @param {Event} event The event to pass to the listener
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function callListener(listener, thisArg, event) {
|
||||||
|
if (typeof listener === 'object' && listener.handleEvent) {
|
||||||
|
listener.handleEvent.call(listener, event);
|
||||||
|
} else {
|
||||||
|
listener.call(thisArg, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { tokenChars } = require('./validation');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an offer to the map of extension offers or a parameter to the map of
|
||||||
|
* parameters.
|
||||||
|
*
|
||||||
|
* @param {Object} dest The map of extension offers or parameters
|
||||||
|
* @param {String} name The extension or parameter name
|
||||||
|
* @param {(Object|Boolean|String)} elem The extension parameters or the
|
||||||
|
* parameter value
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function push(dest, name, elem) {
|
||||||
|
if (dest[name] === undefined) dest[name] = [elem];
|
||||||
|
else dest[name].push(elem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the `Sec-WebSocket-Extensions` header into an object.
|
||||||
|
*
|
||||||
|
* @param {String} header The field value of the header
|
||||||
|
* @return {Object} The parsed object
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function parse(header) {
|
||||||
|
const offers = Object.create(null);
|
||||||
|
let params = Object.create(null);
|
||||||
|
let mustUnescape = false;
|
||||||
|
let isEscaping = false;
|
||||||
|
let inQuotes = false;
|
||||||
|
let extensionName;
|
||||||
|
let paramName;
|
||||||
|
let start = -1;
|
||||||
|
let code = -1;
|
||||||
|
let end = -1;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
for (; i < header.length; i++) {
|
||||||
|
code = header.charCodeAt(i);
|
||||||
|
|
||||||
|
if (extensionName === undefined) {
|
||||||
|
if (end === -1 && tokenChars[code] === 1) {
|
||||||
|
if (start === -1) start = i;
|
||||||
|
} else if (
|
||||||
|
i !== 0 &&
|
||||||
|
(code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
|
||||||
|
) {
|
||||||
|
if (end === -1 && start !== -1) end = i;
|
||||||
|
} else if (code === 0x3b /* ';' */ || code === 0x2c /* ',' */) {
|
||||||
|
if (start === -1) {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end === -1) end = i;
|
||||||
|
const name = header.slice(start, end);
|
||||||
|
if (code === 0x2c) {
|
||||||
|
push(offers, name, params);
|
||||||
|
params = Object.create(null);
|
||||||
|
} else {
|
||||||
|
extensionName = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
start = end = -1;
|
||||||
|
} else {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
} else if (paramName === undefined) {
|
||||||
|
if (end === -1 && tokenChars[code] === 1) {
|
||||||
|
if (start === -1) start = i;
|
||||||
|
} else if (code === 0x20 || code === 0x09) {
|
||||||
|
if (end === -1 && start !== -1) end = i;
|
||||||
|
} else if (code === 0x3b || code === 0x2c) {
|
||||||
|
if (start === -1) {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end === -1) end = i;
|
||||||
|
push(params, header.slice(start, end), true);
|
||||||
|
if (code === 0x2c) {
|
||||||
|
push(offers, extensionName, params);
|
||||||
|
params = Object.create(null);
|
||||||
|
extensionName = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
start = end = -1;
|
||||||
|
} else if (code === 0x3d /* '=' */ && start !== -1 && end === -1) {
|
||||||
|
paramName = header.slice(start, i);
|
||||||
|
start = end = -1;
|
||||||
|
} else {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
//
|
||||||
|
// The value of a quoted-string after unescaping must conform to the
|
||||||
|
// token ABNF, so only token characters are valid.
|
||||||
|
// Ref: https://tools.ietf.org/html/rfc6455#section-9.1
|
||||||
|
//
|
||||||
|
if (isEscaping) {
|
||||||
|
if (tokenChars[code] !== 1) {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
if (start === -1) start = i;
|
||||||
|
else if (!mustUnescape) mustUnescape = true;
|
||||||
|
isEscaping = false;
|
||||||
|
} else if (inQuotes) {
|
||||||
|
if (tokenChars[code] === 1) {
|
||||||
|
if (start === -1) start = i;
|
||||||
|
} else if (code === 0x22 /* '"' */ && start !== -1) {
|
||||||
|
inQuotes = false;
|
||||||
|
end = i;
|
||||||
|
} else if (code === 0x5c /* '\' */) {
|
||||||
|
isEscaping = true;
|
||||||
|
} else {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
} else if (code === 0x22 && header.charCodeAt(i - 1) === 0x3d) {
|
||||||
|
inQuotes = true;
|
||||||
|
} else if (end === -1 && tokenChars[code] === 1) {
|
||||||
|
if (start === -1) start = i;
|
||||||
|
} else if (start !== -1 && (code === 0x20 || code === 0x09)) {
|
||||||
|
if (end === -1) end = i;
|
||||||
|
} else if (code === 0x3b || code === 0x2c) {
|
||||||
|
if (start === -1) {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end === -1) end = i;
|
||||||
|
let value = header.slice(start, end);
|
||||||
|
if (mustUnescape) {
|
||||||
|
value = value.replace(/\\/g, '');
|
||||||
|
mustUnescape = false;
|
||||||
|
}
|
||||||
|
push(params, paramName, value);
|
||||||
|
if (code === 0x2c) {
|
||||||
|
push(offers, extensionName, params);
|
||||||
|
params = Object.create(null);
|
||||||
|
extensionName = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
paramName = undefined;
|
||||||
|
start = end = -1;
|
||||||
|
} else {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start === -1 || inQuotes || code === 0x20 || code === 0x09) {
|
||||||
|
throw new SyntaxError('Unexpected end of input');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end === -1) end = i;
|
||||||
|
const token = header.slice(start, end);
|
||||||
|
if (extensionName === undefined) {
|
||||||
|
push(offers, token, params);
|
||||||
|
} else {
|
||||||
|
if (paramName === undefined) {
|
||||||
|
push(params, token, true);
|
||||||
|
} else if (mustUnescape) {
|
||||||
|
push(params, paramName, token.replace(/\\/g, ''));
|
||||||
|
} else {
|
||||||
|
push(params, paramName, token);
|
||||||
|
}
|
||||||
|
push(offers, extensionName, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
return offers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the `Sec-WebSocket-Extensions` header field value.
|
||||||
|
*
|
||||||
|
* @param {Object} extensions The map of extensions and parameters to format
|
||||||
|
* @return {String} A string representing the given object
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function format(extensions) {
|
||||||
|
return Object.keys(extensions)
|
||||||
|
.map((extension) => {
|
||||||
|
let configurations = extensions[extension];
|
||||||
|
if (!Array.isArray(configurations)) configurations = [configurations];
|
||||||
|
return configurations
|
||||||
|
.map((params) => {
|
||||||
|
return [extension]
|
||||||
|
.concat(
|
||||||
|
Object.keys(params).map((k) => {
|
||||||
|
let values = params[k];
|
||||||
|
if (!Array.isArray(values)) values = [values];
|
||||||
|
return values
|
||||||
|
.map((v) => (v === true ? k : `${k}=${v}`))
|
||||||
|
.join('; ');
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.join('; ');
|
||||||
|
})
|
||||||
|
.join(', ');
|
||||||
|
})
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { format, parse };
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const kDone = Symbol('kDone');
|
||||||
|
const kRun = Symbol('kRun');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A very simple job queue with adjustable concurrency. Adapted from
|
||||||
|
* https://github.com/STRML/async-limiter
|
||||||
|
*/
|
||||||
|
class Limiter {
|
||||||
|
/**
|
||||||
|
* Creates a new `Limiter`.
|
||||||
|
*
|
||||||
|
* @param {Number} [concurrency=Infinity] The maximum number of jobs allowed
|
||||||
|
* to run concurrently
|
||||||
|
*/
|
||||||
|
constructor(concurrency) {
|
||||||
|
this[kDone] = () => {
|
||||||
|
this.pending--;
|
||||||
|
this[kRun]();
|
||||||
|
};
|
||||||
|
this.concurrency = concurrency || Infinity;
|
||||||
|
this.jobs = [];
|
||||||
|
this.pending = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a job to the queue.
|
||||||
|
*
|
||||||
|
* @param {Function} job The job to run
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
add(job) {
|
||||||
|
this.jobs.push(job);
|
||||||
|
this[kRun]();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a job from the queue and runs it if possible.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
[kRun]() {
|
||||||
|
if (this.pending === this.concurrency) return;
|
||||||
|
|
||||||
|
if (this.jobs.length) {
|
||||||
|
const job = this.jobs.shift();
|
||||||
|
|
||||||
|
this.pending++;
|
||||||
|
job(this[kDone]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Limiter;
|
||||||
+530
@@ -0,0 +1,530 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const zlib = require('zlib');
|
||||||
|
|
||||||
|
const bufferUtil = require('./buffer-util');
|
||||||
|
const Limiter = require('./limiter');
|
||||||
|
const { kStatusCode } = require('./constants');
|
||||||
|
|
||||||
|
const FastBuffer = Buffer[Symbol.species];
|
||||||
|
const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]);
|
||||||
|
const kPerMessageDeflate = Symbol('permessage-deflate');
|
||||||
|
const kTotalLength = Symbol('total-length');
|
||||||
|
const kCallback = Symbol('callback');
|
||||||
|
const kBuffers = Symbol('buffers');
|
||||||
|
const kError = Symbol('error');
|
||||||
|
|
||||||
|
//
|
||||||
|
// We limit zlib concurrency, which prevents severe memory fragmentation
|
||||||
|
// as documented in https://github.com/nodejs/node/issues/8871#issuecomment-250915913
|
||||||
|
// and https://github.com/websockets/ws/issues/1202
|
||||||
|
//
|
||||||
|
// Intentionally global; it's the global thread pool that's an issue.
|
||||||
|
//
|
||||||
|
let zlibLimiter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* permessage-deflate implementation.
|
||||||
|
*/
|
||||||
|
class PerMessageDeflate {
|
||||||
|
/**
|
||||||
|
* Creates a PerMessageDeflate instance.
|
||||||
|
*
|
||||||
|
* @param {Object} [options] Configuration options
|
||||||
|
* @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support
|
||||||
|
* for, or request, a custom client window size
|
||||||
|
* @param {Boolean} [options.clientNoContextTakeover=false] Advertise/
|
||||||
|
* acknowledge disabling of client context takeover
|
||||||
|
* @param {Number} [options.concurrencyLimit=10] The number of concurrent
|
||||||
|
* calls to zlib
|
||||||
|
* @param {Boolean} [options.isServer=false] Create the instance in either
|
||||||
|
* server or client mode
|
||||||
|
* @param {Number} [options.maxPayload=0] The maximum allowed message length
|
||||||
|
* @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the
|
||||||
|
* use of a custom server window size
|
||||||
|
* @param {Boolean} [options.serverNoContextTakeover=false] Request/accept
|
||||||
|
* disabling of server context takeover
|
||||||
|
* @param {Number} [options.threshold=1024] Size (in bytes) below which
|
||||||
|
* messages should not be compressed if context takeover is disabled
|
||||||
|
* @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on
|
||||||
|
* deflate
|
||||||
|
* @param {Object} [options.zlibInflateOptions] Options to pass to zlib on
|
||||||
|
* inflate
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this._options = options || {};
|
||||||
|
this._threshold =
|
||||||
|
this._options.threshold !== undefined ? this._options.threshold : 1024;
|
||||||
|
this._maxPayload = this._options.maxPayload | 0;
|
||||||
|
this._isServer = !!this._options.isServer;
|
||||||
|
this._deflate = null;
|
||||||
|
this._inflate = null;
|
||||||
|
|
||||||
|
this.params = null;
|
||||||
|
|
||||||
|
if (!zlibLimiter) {
|
||||||
|
const concurrency =
|
||||||
|
this._options.concurrencyLimit !== undefined
|
||||||
|
? this._options.concurrencyLimit
|
||||||
|
: 10;
|
||||||
|
zlibLimiter = new Limiter(concurrency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {String}
|
||||||
|
*/
|
||||||
|
static get extensionName() {
|
||||||
|
return 'permessage-deflate';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an extension negotiation offer.
|
||||||
|
*
|
||||||
|
* @return {Object} Extension parameters
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
offer() {
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
if (this._options.serverNoContextTakeover) {
|
||||||
|
params.server_no_context_takeover = true;
|
||||||
|
}
|
||||||
|
if (this._options.clientNoContextTakeover) {
|
||||||
|
params.client_no_context_takeover = true;
|
||||||
|
}
|
||||||
|
if (this._options.serverMaxWindowBits) {
|
||||||
|
params.server_max_window_bits = this._options.serverMaxWindowBits;
|
||||||
|
}
|
||||||
|
if (this._options.clientMaxWindowBits) {
|
||||||
|
params.client_max_window_bits = this._options.clientMaxWindowBits;
|
||||||
|
} else if (this._options.clientMaxWindowBits == null) {
|
||||||
|
params.client_max_window_bits = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept an extension negotiation offer/response.
|
||||||
|
*
|
||||||
|
* @param {Array} configurations The extension negotiation offers/reponse
|
||||||
|
* @return {Object} Accepted configuration
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
accept(configurations) {
|
||||||
|
configurations = this.normalizeParams(configurations);
|
||||||
|
|
||||||
|
this.params = this._isServer
|
||||||
|
? this.acceptAsServer(configurations)
|
||||||
|
: this.acceptAsClient(configurations);
|
||||||
|
|
||||||
|
return this.params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases all resources used by the extension.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
cleanup() {
|
||||||
|
if (this._inflate) {
|
||||||
|
this._inflate.close();
|
||||||
|
this._inflate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._deflate) {
|
||||||
|
const callback = this._deflate[kCallback];
|
||||||
|
|
||||||
|
this._deflate.close();
|
||||||
|
this._deflate = null;
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
callback(
|
||||||
|
new Error(
|
||||||
|
'The deflate stream was closed while data was being processed'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept an extension negotiation offer.
|
||||||
|
*
|
||||||
|
* @param {Array} offers The extension negotiation offers
|
||||||
|
* @return {Object} Accepted configuration
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
acceptAsServer(offers) {
|
||||||
|
const opts = this._options;
|
||||||
|
const accepted = offers.find((params) => {
|
||||||
|
if (
|
||||||
|
(opts.serverNoContextTakeover === false &&
|
||||||
|
params.server_no_context_takeover) ||
|
||||||
|
(params.server_max_window_bits &&
|
||||||
|
(opts.serverMaxWindowBits === false ||
|
||||||
|
(typeof opts.serverMaxWindowBits === 'number' &&
|
||||||
|
opts.serverMaxWindowBits > params.server_max_window_bits))) ||
|
||||||
|
(typeof opts.clientMaxWindowBits === 'number' &&
|
||||||
|
(typeof params.client_max_window_bits === 'number'
|
||||||
|
? opts.clientMaxWindowBits > params.client_max_window_bits
|
||||||
|
: !params.client_max_window_bits))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accepted) {
|
||||||
|
throw new Error('None of the extension offers can be accepted');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.serverNoContextTakeover) {
|
||||||
|
accepted.server_no_context_takeover = true;
|
||||||
|
}
|
||||||
|
if (opts.clientNoContextTakeover) {
|
||||||
|
accepted.client_no_context_takeover = true;
|
||||||
|
}
|
||||||
|
if (typeof opts.serverMaxWindowBits === 'number') {
|
||||||
|
accepted.server_max_window_bits = opts.serverMaxWindowBits;
|
||||||
|
}
|
||||||
|
if (typeof opts.clientMaxWindowBits === 'number') {
|
||||||
|
accepted.client_max_window_bits = opts.clientMaxWindowBits;
|
||||||
|
} else if (
|
||||||
|
accepted.client_max_window_bits === true ||
|
||||||
|
opts.clientMaxWindowBits === false
|
||||||
|
) {
|
||||||
|
delete accepted.client_max_window_bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept the extension negotiation response.
|
||||||
|
*
|
||||||
|
* @param {Array} response The extension negotiation response
|
||||||
|
* @return {Object} Accepted configuration
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
acceptAsClient(response) {
|
||||||
|
const params = response[0];
|
||||||
|
|
||||||
|
if (
|
||||||
|
this._options.clientNoContextTakeover === false &&
|
||||||
|
params.client_no_context_takeover
|
||||||
|
) {
|
||||||
|
throw new Error('Unexpected parameter "client_no_context_takeover"');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params.client_max_window_bits) {
|
||||||
|
if (typeof this._options.clientMaxWindowBits === 'number') {
|
||||||
|
params.client_max_window_bits = this._options.clientMaxWindowBits;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
this._options.clientMaxWindowBits === false ||
|
||||||
|
(typeof this._options.clientMaxWindowBits === 'number' &&
|
||||||
|
params.client_max_window_bits > this._options.clientMaxWindowBits)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Unexpected or invalid parameter "client_max_window_bits"'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize parameters.
|
||||||
|
*
|
||||||
|
* @param {Array} configurations The extension negotiation offers/reponse
|
||||||
|
* @return {Array} The offers/response with normalized parameters
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
normalizeParams(configurations) {
|
||||||
|
configurations.forEach((params) => {
|
||||||
|
Object.keys(params).forEach((key) => {
|
||||||
|
let value = params[key];
|
||||||
|
|
||||||
|
if (value.length > 1) {
|
||||||
|
throw new Error(`Parameter "${key}" must have only a single value`);
|
||||||
|
}
|
||||||
|
|
||||||
|
value = value[0];
|
||||||
|
|
||||||
|
if (key === 'client_max_window_bits') {
|
||||||
|
if (value !== true) {
|
||||||
|
const num = +value;
|
||||||
|
if (!Number.isInteger(num) || num < 8 || num > 15) {
|
||||||
|
throw new TypeError(
|
||||||
|
`Invalid value for parameter "${key}": ${value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
value = num;
|
||||||
|
} else if (!this._isServer) {
|
||||||
|
throw new TypeError(
|
||||||
|
`Invalid value for parameter "${key}": ${value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (key === 'server_max_window_bits') {
|
||||||
|
const num = +value;
|
||||||
|
if (!Number.isInteger(num) || num < 8 || num > 15) {
|
||||||
|
throw new TypeError(
|
||||||
|
`Invalid value for parameter "${key}": ${value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
value = num;
|
||||||
|
} else if (
|
||||||
|
key === 'client_no_context_takeover' ||
|
||||||
|
key === 'server_no_context_takeover'
|
||||||
|
) {
|
||||||
|
if (value !== true) {
|
||||||
|
throw new TypeError(
|
||||||
|
`Invalid value for parameter "${key}": ${value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown parameter "${key}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
params[key] = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return configurations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decompress data. Concurrency limited.
|
||||||
|
*
|
||||||
|
* @param {Buffer} data Compressed data
|
||||||
|
* @param {Boolean} fin Specifies whether or not this is the last fragment
|
||||||
|
* @param {Function} callback Callback
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
decompress(data, fin, callback) {
|
||||||
|
zlibLimiter.add((done) => {
|
||||||
|
this._decompress(data, fin, (err, result) => {
|
||||||
|
done();
|
||||||
|
callback(err, result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compress data. Concurrency limited.
|
||||||
|
*
|
||||||
|
* @param {(Buffer|String)} data Data to compress
|
||||||
|
* @param {Boolean} fin Specifies whether or not this is the last fragment
|
||||||
|
* @param {Function} callback Callback
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
compress(data, fin, callback) {
|
||||||
|
zlibLimiter.add((done) => {
|
||||||
|
this._compress(data, fin, (err, result) => {
|
||||||
|
done();
|
||||||
|
callback(err, result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decompress data.
|
||||||
|
*
|
||||||
|
* @param {Buffer} data Compressed data
|
||||||
|
* @param {Boolean} fin Specifies whether or not this is the last fragment
|
||||||
|
* @param {Function} callback Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_decompress(data, fin, callback) {
|
||||||
|
const endpoint = this._isServer ? 'client' : 'server';
|
||||||
|
|
||||||
|
if (!this._inflate) {
|
||||||
|
const key = `${endpoint}_max_window_bits`;
|
||||||
|
const windowBits =
|
||||||
|
typeof this.params[key] !== 'number'
|
||||||
|
? zlib.Z_DEFAULT_WINDOWBITS
|
||||||
|
: this.params[key];
|
||||||
|
|
||||||
|
this._inflate = zlib.createInflateRaw({
|
||||||
|
...this._options.zlibInflateOptions,
|
||||||
|
windowBits
|
||||||
|
});
|
||||||
|
this._inflate[kPerMessageDeflate] = this;
|
||||||
|
this._inflate[kTotalLength] = 0;
|
||||||
|
this._inflate[kBuffers] = [];
|
||||||
|
this._inflate.on('error', inflateOnError);
|
||||||
|
this._inflate.on('data', inflateOnData);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._inflate[kCallback] = callback;
|
||||||
|
|
||||||
|
this._inflate.write(data);
|
||||||
|
if (fin) this._inflate.write(TRAILER);
|
||||||
|
|
||||||
|
this._inflate.flush(() => {
|
||||||
|
const err = this._inflate[kError];
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
this._inflate.close();
|
||||||
|
this._inflate = null;
|
||||||
|
callback(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = bufferUtil.concat(
|
||||||
|
this._inflate[kBuffers],
|
||||||
|
this._inflate[kTotalLength]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this._inflate._readableState.endEmitted) {
|
||||||
|
this._inflate.close();
|
||||||
|
this._inflate = null;
|
||||||
|
} else {
|
||||||
|
this._inflate[kTotalLength] = 0;
|
||||||
|
this._inflate[kBuffers] = [];
|
||||||
|
|
||||||
|
if (fin && this.params[`${endpoint}_no_context_takeover`]) {
|
||||||
|
this._inflate.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(null, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compress data.
|
||||||
|
*
|
||||||
|
* @param {(Buffer|String)} data Data to compress
|
||||||
|
* @param {Boolean} fin Specifies whether or not this is the last fragment
|
||||||
|
* @param {Function} callback Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_compress(data, fin, callback) {
|
||||||
|
const endpoint = this._isServer ? 'server' : 'client';
|
||||||
|
|
||||||
|
if (!this._deflate) {
|
||||||
|
const key = `${endpoint}_max_window_bits`;
|
||||||
|
const windowBits =
|
||||||
|
typeof this.params[key] !== 'number'
|
||||||
|
? zlib.Z_DEFAULT_WINDOWBITS
|
||||||
|
: this.params[key];
|
||||||
|
|
||||||
|
this._deflate = zlib.createDeflateRaw({
|
||||||
|
...this._options.zlibDeflateOptions,
|
||||||
|
windowBits
|
||||||
|
});
|
||||||
|
|
||||||
|
this._deflate[kTotalLength] = 0;
|
||||||
|
this._deflate[kBuffers] = [];
|
||||||
|
|
||||||
|
this._deflate.on('data', deflateOnData);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._deflate[kCallback] = callback;
|
||||||
|
|
||||||
|
this._deflate.write(data);
|
||||||
|
this._deflate.flush(zlib.Z_SYNC_FLUSH, () => {
|
||||||
|
if (!this._deflate) {
|
||||||
|
//
|
||||||
|
// The deflate stream was closed while data was being processed.
|
||||||
|
//
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = bufferUtil.concat(
|
||||||
|
this._deflate[kBuffers],
|
||||||
|
this._deflate[kTotalLength]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fin) {
|
||||||
|
data = new FastBuffer(data.buffer, data.byteOffset, data.length - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Ensure that the callback will not be called again in
|
||||||
|
// `PerMessageDeflate#cleanup()`.
|
||||||
|
//
|
||||||
|
this._deflate[kCallback] = null;
|
||||||
|
|
||||||
|
this._deflate[kTotalLength] = 0;
|
||||||
|
this._deflate[kBuffers] = [];
|
||||||
|
|
||||||
|
if (fin && this.params[`${endpoint}_no_context_takeover`]) {
|
||||||
|
this._deflate.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(null, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PerMessageDeflate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The listener of the `zlib.DeflateRaw` stream `'data'` event.
|
||||||
|
*
|
||||||
|
* @param {Buffer} chunk A chunk of data
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function deflateOnData(chunk) {
|
||||||
|
this[kBuffers].push(chunk);
|
||||||
|
this[kTotalLength] += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The listener of the `zlib.InflateRaw` stream `'data'` event.
|
||||||
|
*
|
||||||
|
* @param {Buffer} chunk A chunk of data
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function inflateOnData(chunk) {
|
||||||
|
this[kTotalLength] += chunk.length;
|
||||||
|
|
||||||
|
if (
|
||||||
|
this[kPerMessageDeflate]._maxPayload < 1 ||
|
||||||
|
this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload
|
||||||
|
) {
|
||||||
|
this[kBuffers].push(chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this[kError] = new RangeError('Max payload size exceeded');
|
||||||
|
this[kError].code = 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH';
|
||||||
|
this[kError][kStatusCode] = 1009;
|
||||||
|
this.removeListener('data', inflateOnData);
|
||||||
|
|
||||||
|
//
|
||||||
|
// The choice to employ `zlib.reset()` over `zlib.close()` is dictated by the
|
||||||
|
// fact that in Node.js versions prior to 13.10.0, the callback for
|
||||||
|
// `zlib.flush()` is not called if `zlib.close()` is used. Utilizing
|
||||||
|
// `zlib.reset()` ensures that either the callback is invoked or an error is
|
||||||
|
// emitted.
|
||||||
|
//
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The listener of the `zlib.InflateRaw` stream `'error'` event.
|
||||||
|
*
|
||||||
|
* @param {Error} err The emitted error
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function inflateOnError(err) {
|
||||||
|
//
|
||||||
|
// There is no need to call `Zlib#close()` as the handle is automatically
|
||||||
|
// closed when an error is emitted.
|
||||||
|
//
|
||||||
|
this[kPerMessageDeflate]._inflate = null;
|
||||||
|
|
||||||
|
if (this[kError]) {
|
||||||
|
this[kCallback](this[kError]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
err[kStatusCode] = 1007;
|
||||||
|
this[kCallback](err);
|
||||||
|
}
|
||||||
+743
@@ -0,0 +1,743 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { Writable } = require('stream');
|
||||||
|
|
||||||
|
const PerMessageDeflate = require('./permessage-deflate');
|
||||||
|
const {
|
||||||
|
BINARY_TYPES,
|
||||||
|
EMPTY_BUFFER,
|
||||||
|
kStatusCode,
|
||||||
|
kWebSocket
|
||||||
|
} = require('./constants');
|
||||||
|
const { concat, toArrayBuffer, unmask } = require('./buffer-util');
|
||||||
|
const { isValidStatusCode, isValidUTF8 } = require('./validation');
|
||||||
|
|
||||||
|
const FastBuffer = Buffer[Symbol.species];
|
||||||
|
|
||||||
|
const GET_INFO = 0;
|
||||||
|
const GET_PAYLOAD_LENGTH_16 = 1;
|
||||||
|
const GET_PAYLOAD_LENGTH_64 = 2;
|
||||||
|
const GET_MASK = 3;
|
||||||
|
const GET_DATA = 4;
|
||||||
|
const INFLATING = 5;
|
||||||
|
const DEFER_EVENT = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HyBi Receiver implementation.
|
||||||
|
*
|
||||||
|
* @extends Writable
|
||||||
|
*/
|
||||||
|
class Receiver extends Writable {
|
||||||
|
/**
|
||||||
|
* Creates a Receiver instance.
|
||||||
|
*
|
||||||
|
* @param {Object} [options] Options object
|
||||||
|
* @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether
|
||||||
|
* any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
|
||||||
|
* multiple times in the same tick
|
||||||
|
* @param {String} [options.binaryType=nodebuffer] The type for binary data
|
||||||
|
* @param {Object} [options.extensions] An object containing the negotiated
|
||||||
|
* extensions
|
||||||
|
* @param {Boolean} [options.isServer=false] Specifies whether to operate in
|
||||||
|
* client or server mode
|
||||||
|
* @param {Number} [options.maxBufferedChunks=0] The maximum number of
|
||||||
|
* buffered data chunks
|
||||||
|
* @param {Number} [options.maxFragments=0] The maximum number of message
|
||||||
|
* fragments
|
||||||
|
* @param {Number} [options.maxPayload=0] The maximum allowed message length
|
||||||
|
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
|
||||||
|
* not to skip UTF-8 validation for text and close messages
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this._allowSynchronousEvents =
|
||||||
|
options.allowSynchronousEvents !== undefined
|
||||||
|
? options.allowSynchronousEvents
|
||||||
|
: true;
|
||||||
|
this._binaryType = options.binaryType || BINARY_TYPES[0];
|
||||||
|
this._extensions = options.extensions || {};
|
||||||
|
this._isServer = !!options.isServer;
|
||||||
|
this._maxBufferedChunks = options.maxBufferedChunks | 0;
|
||||||
|
this._maxFragments = options.maxFragments | 0;
|
||||||
|
this._maxPayload = options.maxPayload | 0;
|
||||||
|
this._skipUTF8Validation = !!options.skipUTF8Validation;
|
||||||
|
this[kWebSocket] = undefined;
|
||||||
|
|
||||||
|
this._bufferedBytes = 0;
|
||||||
|
this._buffers = [];
|
||||||
|
|
||||||
|
this._compressed = false;
|
||||||
|
this._payloadLength = 0;
|
||||||
|
this._mask = undefined;
|
||||||
|
this._fragmented = 0;
|
||||||
|
this._masked = false;
|
||||||
|
this._fin = false;
|
||||||
|
this._opcode = 0;
|
||||||
|
|
||||||
|
this._totalPayloadLength = 0;
|
||||||
|
this._messageLength = 0;
|
||||||
|
this._numFragments = 0;
|
||||||
|
this._fragments = [];
|
||||||
|
|
||||||
|
this._errored = false;
|
||||||
|
this._loop = false;
|
||||||
|
this._state = GET_INFO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements `Writable.prototype._write()`.
|
||||||
|
*
|
||||||
|
* @param {Buffer} chunk The chunk of data to write
|
||||||
|
* @param {String} encoding The character encoding of `chunk`
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_write(chunk, encoding, cb) {
|
||||||
|
if (this._opcode === 0x08 && this._state == GET_INFO) return cb();
|
||||||
|
|
||||||
|
if (
|
||||||
|
this._maxBufferedChunks > 0 &&
|
||||||
|
this._buffers.length >= this._maxBufferedChunks
|
||||||
|
) {
|
||||||
|
cb(
|
||||||
|
this.createError(
|
||||||
|
RangeError,
|
||||||
|
'Too many buffered chunks',
|
||||||
|
false,
|
||||||
|
1008,
|
||||||
|
'WS_ERR_TOO_MANY_BUFFERED_PARTS'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._bufferedBytes += chunk.length;
|
||||||
|
this._buffers.push(chunk);
|
||||||
|
this.startLoop(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumes `n` bytes from the buffered data.
|
||||||
|
*
|
||||||
|
* @param {Number} n The number of bytes to consume
|
||||||
|
* @return {Buffer} The consumed bytes
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
consume(n) {
|
||||||
|
this._bufferedBytes -= n;
|
||||||
|
|
||||||
|
if (n === this._buffers[0].length) return this._buffers.shift();
|
||||||
|
|
||||||
|
if (n < this._buffers[0].length) {
|
||||||
|
const buf = this._buffers[0];
|
||||||
|
this._buffers[0] = new FastBuffer(
|
||||||
|
buf.buffer,
|
||||||
|
buf.byteOffset + n,
|
||||||
|
buf.length - n
|
||||||
|
);
|
||||||
|
|
||||||
|
return new FastBuffer(buf.buffer, buf.byteOffset, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dst = Buffer.allocUnsafe(n);
|
||||||
|
|
||||||
|
do {
|
||||||
|
const buf = this._buffers[0];
|
||||||
|
const offset = dst.length - n;
|
||||||
|
|
||||||
|
if (n >= buf.length) {
|
||||||
|
dst.set(this._buffers.shift(), offset);
|
||||||
|
} else {
|
||||||
|
dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset);
|
||||||
|
this._buffers[0] = new FastBuffer(
|
||||||
|
buf.buffer,
|
||||||
|
buf.byteOffset + n,
|
||||||
|
buf.length - n
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
n -= buf.length;
|
||||||
|
} while (n > 0);
|
||||||
|
|
||||||
|
return dst;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the parsing loop.
|
||||||
|
*
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
startLoop(cb) {
|
||||||
|
this._loop = true;
|
||||||
|
|
||||||
|
do {
|
||||||
|
switch (this._state) {
|
||||||
|
case GET_INFO:
|
||||||
|
this.getInfo(cb);
|
||||||
|
break;
|
||||||
|
case GET_PAYLOAD_LENGTH_16:
|
||||||
|
this.getPayloadLength16(cb);
|
||||||
|
break;
|
||||||
|
case GET_PAYLOAD_LENGTH_64:
|
||||||
|
this.getPayloadLength64(cb);
|
||||||
|
break;
|
||||||
|
case GET_MASK:
|
||||||
|
this.getMask();
|
||||||
|
break;
|
||||||
|
case GET_DATA:
|
||||||
|
this.getData(cb);
|
||||||
|
break;
|
||||||
|
case INFLATING:
|
||||||
|
case DEFER_EVENT:
|
||||||
|
this._loop = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} while (this._loop);
|
||||||
|
|
||||||
|
if (!this._errored) cb();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the first two bytes of a frame.
|
||||||
|
*
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
getInfo(cb) {
|
||||||
|
if (this._bufferedBytes < 2) {
|
||||||
|
this._loop = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = this.consume(2);
|
||||||
|
|
||||||
|
if ((buf[0] & 0x30) !== 0x00) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'RSV2 and RSV3 must be clear',
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_UNEXPECTED_RSV_2_3'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compressed = (buf[0] & 0x40) === 0x40;
|
||||||
|
|
||||||
|
if (compressed && !this._extensions[PerMessageDeflate.extensionName]) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'RSV1 must be clear',
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_UNEXPECTED_RSV_1'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._fin = (buf[0] & 0x80) === 0x80;
|
||||||
|
this._opcode = buf[0] & 0x0f;
|
||||||
|
this._payloadLength = buf[1] & 0x7f;
|
||||||
|
|
||||||
|
if (this._opcode === 0x00) {
|
||||||
|
if (compressed) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'RSV1 must be clear',
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_UNEXPECTED_RSV_1'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._fragmented) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'invalid opcode 0',
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_INVALID_OPCODE'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._opcode = this._fragmented;
|
||||||
|
} else if (this._opcode === 0x01 || this._opcode === 0x02) {
|
||||||
|
if (this._fragmented) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
`invalid opcode ${this._opcode}`,
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_INVALID_OPCODE'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._compressed = compressed;
|
||||||
|
} else if (this._opcode > 0x07 && this._opcode < 0x0b) {
|
||||||
|
if (!this._fin) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'FIN must be set',
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_EXPECTED_FIN'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compressed) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'RSV1 must be clear',
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_UNEXPECTED_RSV_1'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this._payloadLength > 0x7d ||
|
||||||
|
(this._opcode === 0x08 && this._payloadLength === 1)
|
||||||
|
) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
`invalid payload length ${this._payloadLength}`,
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
`invalid opcode ${this._opcode}`,
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_INVALID_OPCODE'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._fin && !this._fragmented) this._fragmented = this._opcode;
|
||||||
|
this._masked = (buf[1] & 0x80) === 0x80;
|
||||||
|
|
||||||
|
if (this._isServer) {
|
||||||
|
if (!this._masked) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'MASK must be set',
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_EXPECTED_MASK'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (this._masked) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'MASK must be clear',
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_UNEXPECTED_MASK'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16;
|
||||||
|
else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64;
|
||||||
|
else this.haveLength(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets extended payload length (7+16).
|
||||||
|
*
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
getPayloadLength16(cb) {
|
||||||
|
if (this._bufferedBytes < 2) {
|
||||||
|
this._loop = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._payloadLength = this.consume(2).readUInt16BE(0);
|
||||||
|
this.haveLength(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets extended payload length (7+64).
|
||||||
|
*
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
getPayloadLength64(cb) {
|
||||||
|
if (this._bufferedBytes < 8) {
|
||||||
|
this._loop = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = this.consume(8);
|
||||||
|
const num = buf.readUInt32BE(0);
|
||||||
|
|
||||||
|
//
|
||||||
|
// The maximum safe integer in JavaScript is 2^53 - 1. An error is returned
|
||||||
|
// if payload length is greater than this number.
|
||||||
|
//
|
||||||
|
if (num > Math.pow(2, 53 - 32) - 1) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'Unsupported WebSocket frame: payload length > 2^53 - 1',
|
||||||
|
false,
|
||||||
|
1009,
|
||||||
|
'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4);
|
||||||
|
this.haveLength(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload length has been read.
|
||||||
|
*
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
haveLength(cb) {
|
||||||
|
if (this._payloadLength && this._opcode < 0x08) {
|
||||||
|
this._totalPayloadLength += this._payloadLength;
|
||||||
|
if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'Max payload size exceeded',
|
||||||
|
false,
|
||||||
|
1009,
|
||||||
|
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._masked) this._state = GET_MASK;
|
||||||
|
else this._state = GET_DATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads mask bytes.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
getMask() {
|
||||||
|
if (this._bufferedBytes < 4) {
|
||||||
|
this._loop = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._mask = this.consume(4);
|
||||||
|
this._state = GET_DATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads data bytes.
|
||||||
|
*
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
getData(cb) {
|
||||||
|
let data = EMPTY_BUFFER;
|
||||||
|
|
||||||
|
if (this._payloadLength) {
|
||||||
|
if (this._bufferedBytes < this._payloadLength) {
|
||||||
|
this._loop = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data = this.consume(this._payloadLength);
|
||||||
|
|
||||||
|
if (
|
||||||
|
this._masked &&
|
||||||
|
(this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0
|
||||||
|
) {
|
||||||
|
unmask(data, this._mask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._opcode > 0x07) {
|
||||||
|
this.controlMessage(data, cb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._maxFragments > 0 && ++this._numFragments > this._maxFragments) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'Too many message fragments',
|
||||||
|
false,
|
||||||
|
1008,
|
||||||
|
'WS_ERR_TOO_MANY_BUFFERED_PARTS'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._compressed) {
|
||||||
|
this._state = INFLATING;
|
||||||
|
this.decompress(data, cb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length) {
|
||||||
|
//
|
||||||
|
// This message is not compressed so its length is the sum of the payload
|
||||||
|
// length of all fragments.
|
||||||
|
//
|
||||||
|
this._messageLength = this._totalPayloadLength;
|
||||||
|
this._fragments.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataMessage(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decompresses data.
|
||||||
|
*
|
||||||
|
* @param {Buffer} data Compressed data
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
decompress(data, cb) {
|
||||||
|
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
|
||||||
|
|
||||||
|
perMessageDeflate.decompress(data, this._fin, (err, buf) => {
|
||||||
|
if (err) return cb(err);
|
||||||
|
|
||||||
|
if (buf.length) {
|
||||||
|
this._messageLength += buf.length;
|
||||||
|
if (this._messageLength > this._maxPayload && this._maxPayload > 0) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'Max payload size exceeded',
|
||||||
|
false,
|
||||||
|
1009,
|
||||||
|
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._fragments.push(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataMessage(cb);
|
||||||
|
if (this._state === GET_INFO) this.startLoop(cb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a data message.
|
||||||
|
*
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
dataMessage(cb) {
|
||||||
|
if (!this._fin) {
|
||||||
|
this._state = GET_INFO;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageLength = this._messageLength;
|
||||||
|
const fragments = this._fragments;
|
||||||
|
|
||||||
|
this._totalPayloadLength = 0;
|
||||||
|
this._messageLength = 0;
|
||||||
|
this._fragmented = 0;
|
||||||
|
this._numFragments = 0;
|
||||||
|
this._fragments = [];
|
||||||
|
|
||||||
|
if (this._opcode === 2) {
|
||||||
|
let data;
|
||||||
|
|
||||||
|
if (this._binaryType === 'nodebuffer') {
|
||||||
|
data = concat(fragments, messageLength);
|
||||||
|
} else if (this._binaryType === 'arraybuffer') {
|
||||||
|
data = toArrayBuffer(concat(fragments, messageLength));
|
||||||
|
} else if (this._binaryType === 'blob') {
|
||||||
|
data = new Blob(fragments);
|
||||||
|
} else {
|
||||||
|
data = fragments;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._allowSynchronousEvents) {
|
||||||
|
this.emit('message', data, true);
|
||||||
|
this._state = GET_INFO;
|
||||||
|
} else {
|
||||||
|
this._state = DEFER_EVENT;
|
||||||
|
setImmediate(() => {
|
||||||
|
this.emit('message', data, true);
|
||||||
|
this._state = GET_INFO;
|
||||||
|
this.startLoop(cb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const buf = concat(fragments, messageLength);
|
||||||
|
|
||||||
|
if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
|
||||||
|
const error = this.createError(
|
||||||
|
Error,
|
||||||
|
'invalid UTF-8 sequence',
|
||||||
|
true,
|
||||||
|
1007,
|
||||||
|
'WS_ERR_INVALID_UTF8'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._state === INFLATING || this._allowSynchronousEvents) {
|
||||||
|
this.emit('message', buf, false);
|
||||||
|
this._state = GET_INFO;
|
||||||
|
} else {
|
||||||
|
this._state = DEFER_EVENT;
|
||||||
|
setImmediate(() => {
|
||||||
|
this.emit('message', buf, false);
|
||||||
|
this._state = GET_INFO;
|
||||||
|
this.startLoop(cb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a control message.
|
||||||
|
*
|
||||||
|
* @param {Buffer} data Data to handle
|
||||||
|
* @return {(Error|RangeError|undefined)} A possible error
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
controlMessage(data, cb) {
|
||||||
|
if (this._opcode === 0x08) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
this._loop = false;
|
||||||
|
this.emit('conclude', 1005, EMPTY_BUFFER);
|
||||||
|
this.end();
|
||||||
|
} else {
|
||||||
|
const code = data.readUInt16BE(0);
|
||||||
|
|
||||||
|
if (!isValidStatusCode(code)) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
`invalid status code ${code}`,
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_INVALID_CLOSE_CODE'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = new FastBuffer(
|
||||||
|
data.buffer,
|
||||||
|
data.byteOffset + 2,
|
||||||
|
data.length - 2
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
|
||||||
|
const error = this.createError(
|
||||||
|
Error,
|
||||||
|
'invalid UTF-8 sequence',
|
||||||
|
true,
|
||||||
|
1007,
|
||||||
|
'WS_ERR_INVALID_UTF8'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._loop = false;
|
||||||
|
this.emit('conclude', code, buf);
|
||||||
|
this.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._state = GET_INFO;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._allowSynchronousEvents) {
|
||||||
|
this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
|
||||||
|
this._state = GET_INFO;
|
||||||
|
} else {
|
||||||
|
this._state = DEFER_EVENT;
|
||||||
|
setImmediate(() => {
|
||||||
|
this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
|
||||||
|
this._state = GET_INFO;
|
||||||
|
this.startLoop(cb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an error object.
|
||||||
|
*
|
||||||
|
* @param {function(new:Error|RangeError)} ErrorCtor The error constructor
|
||||||
|
* @param {String} message The error message
|
||||||
|
* @param {Boolean} prefix Specifies whether or not to add a default prefix to
|
||||||
|
* `message`
|
||||||
|
* @param {Number} statusCode The status code
|
||||||
|
* @param {String} errorCode The exposed error code
|
||||||
|
* @return {(Error|RangeError)} The error
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
createError(ErrorCtor, message, prefix, statusCode, errorCode) {
|
||||||
|
this._loop = false;
|
||||||
|
this._errored = true;
|
||||||
|
|
||||||
|
const err = new ErrorCtor(
|
||||||
|
prefix ? `Invalid WebSocket frame: ${message}` : message
|
||||||
|
);
|
||||||
|
|
||||||
|
Error.captureStackTrace(err, this.createError);
|
||||||
|
err.code = errorCode;
|
||||||
|
err[kStatusCode] = statusCode;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Receiver;
|
||||||
+607
@@ -0,0 +1,607 @@
|
|||||||
|
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex" }] */
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { Duplex } = require('stream');
|
||||||
|
const { randomFillSync } = require('crypto');
|
||||||
|
const {
|
||||||
|
types: { isUint8Array }
|
||||||
|
} = require('util');
|
||||||
|
|
||||||
|
const PerMessageDeflate = require('./permessage-deflate');
|
||||||
|
const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants');
|
||||||
|
const { isBlob, isValidStatusCode } = require('./validation');
|
||||||
|
const { mask: applyMask, toBuffer } = require('./buffer-util');
|
||||||
|
|
||||||
|
const kByteLength = Symbol('kByteLength');
|
||||||
|
const maskBuffer = Buffer.alloc(4);
|
||||||
|
const RANDOM_POOL_SIZE = 8 * 1024;
|
||||||
|
let randomPool;
|
||||||
|
let randomPoolPointer = RANDOM_POOL_SIZE;
|
||||||
|
|
||||||
|
const DEFAULT = 0;
|
||||||
|
const DEFLATING = 1;
|
||||||
|
const GET_BLOB_DATA = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HyBi Sender implementation.
|
||||||
|
*/
|
||||||
|
class Sender {
|
||||||
|
/**
|
||||||
|
* Creates a Sender instance.
|
||||||
|
*
|
||||||
|
* @param {Duplex} socket The connection socket
|
||||||
|
* @param {Object} [extensions] An object containing the negotiated extensions
|
||||||
|
* @param {Function} [generateMask] The function used to generate the masking
|
||||||
|
* key
|
||||||
|
*/
|
||||||
|
constructor(socket, extensions, generateMask) {
|
||||||
|
this._extensions = extensions || {};
|
||||||
|
|
||||||
|
if (generateMask) {
|
||||||
|
this._generateMask = generateMask;
|
||||||
|
this._maskBuffer = Buffer.alloc(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._socket = socket;
|
||||||
|
|
||||||
|
this._firstFragment = true;
|
||||||
|
this._compress = false;
|
||||||
|
|
||||||
|
this._bufferedBytes = 0;
|
||||||
|
this._queue = [];
|
||||||
|
this._state = DEFAULT;
|
||||||
|
this.onerror = NOOP;
|
||||||
|
this[kWebSocket] = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frames a piece of data according to the HyBi WebSocket protocol.
|
||||||
|
*
|
||||||
|
* @param {(Buffer|String)} data The data to frame
|
||||||
|
* @param {Object} options Options object
|
||||||
|
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
|
||||||
|
* FIN bit
|
||||||
|
* @param {Function} [options.generateMask] The function used to generate the
|
||||||
|
* masking key
|
||||||
|
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
|
||||||
|
* `data`
|
||||||
|
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
|
||||||
|
* key
|
||||||
|
* @param {Number} options.opcode The opcode
|
||||||
|
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
|
||||||
|
* modified
|
||||||
|
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
|
||||||
|
* RSV1 bit
|
||||||
|
* @return {(Buffer|String)[]} The framed data
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
static frame(data, options) {
|
||||||
|
let mask;
|
||||||
|
let merge = false;
|
||||||
|
let offset = 2;
|
||||||
|
let skipMasking = false;
|
||||||
|
|
||||||
|
if (options.mask) {
|
||||||
|
mask = options.maskBuffer || maskBuffer;
|
||||||
|
|
||||||
|
if (options.generateMask) {
|
||||||
|
options.generateMask(mask);
|
||||||
|
} else {
|
||||||
|
if (randomPoolPointer === RANDOM_POOL_SIZE) {
|
||||||
|
/* istanbul ignore else */
|
||||||
|
if (randomPool === undefined) {
|
||||||
|
//
|
||||||
|
// This is lazily initialized because server-sent frames must not
|
||||||
|
// be masked so it may never be used.
|
||||||
|
//
|
||||||
|
randomPool = Buffer.alloc(RANDOM_POOL_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
randomFillSync(randomPool, 0, RANDOM_POOL_SIZE);
|
||||||
|
randomPoolPointer = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
mask[0] = randomPool[randomPoolPointer++];
|
||||||
|
mask[1] = randomPool[randomPoolPointer++];
|
||||||
|
mask[2] = randomPool[randomPoolPointer++];
|
||||||
|
mask[3] = randomPool[randomPoolPointer++];
|
||||||
|
}
|
||||||
|
|
||||||
|
skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0;
|
||||||
|
offset = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dataLength;
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
if (
|
||||||
|
(!options.mask || skipMasking) &&
|
||||||
|
options[kByteLength] !== undefined
|
||||||
|
) {
|
||||||
|
dataLength = options[kByteLength];
|
||||||
|
} else {
|
||||||
|
data = Buffer.from(data);
|
||||||
|
dataLength = data.length;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dataLength = data.length;
|
||||||
|
merge = options.mask && options.readOnly && !skipMasking;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payloadLength = dataLength;
|
||||||
|
|
||||||
|
if (dataLength >= 65536) {
|
||||||
|
offset += 8;
|
||||||
|
payloadLength = 127;
|
||||||
|
} else if (dataLength > 125) {
|
||||||
|
offset += 2;
|
||||||
|
payloadLength = 126;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = Buffer.allocUnsafe(merge ? dataLength + offset : offset);
|
||||||
|
|
||||||
|
target[0] = options.fin ? options.opcode | 0x80 : options.opcode;
|
||||||
|
if (options.rsv1) target[0] |= 0x40;
|
||||||
|
|
||||||
|
target[1] = payloadLength;
|
||||||
|
|
||||||
|
if (payloadLength === 126) {
|
||||||
|
target.writeUInt16BE(dataLength, 2);
|
||||||
|
} else if (payloadLength === 127) {
|
||||||
|
target[2] = target[3] = 0;
|
||||||
|
target.writeUIntBE(dataLength, 4, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.mask) return [target, data];
|
||||||
|
|
||||||
|
target[1] |= 0x80;
|
||||||
|
target[offset - 4] = mask[0];
|
||||||
|
target[offset - 3] = mask[1];
|
||||||
|
target[offset - 2] = mask[2];
|
||||||
|
target[offset - 1] = mask[3];
|
||||||
|
|
||||||
|
if (skipMasking) return [target, data];
|
||||||
|
|
||||||
|
if (merge) {
|
||||||
|
applyMask(data, mask, target, offset, dataLength);
|
||||||
|
return [target];
|
||||||
|
}
|
||||||
|
|
||||||
|
applyMask(data, mask, data, 0, dataLength);
|
||||||
|
return [target, data];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a close message to the other peer.
|
||||||
|
*
|
||||||
|
* @param {Number} [code] The status code component of the body
|
||||||
|
* @param {(String|Buffer)} [data] The message component of the body
|
||||||
|
* @param {Boolean} [mask=false] Specifies whether or not to mask the message
|
||||||
|
* @param {Function} [cb] Callback
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
close(code, data, mask, cb) {
|
||||||
|
let buf;
|
||||||
|
|
||||||
|
if (code === undefined) {
|
||||||
|
buf = EMPTY_BUFFER;
|
||||||
|
} else if (typeof code !== 'number' || !isValidStatusCode(code)) {
|
||||||
|
throw new TypeError('First argument must be a valid error code number');
|
||||||
|
} else if (data === undefined || !data.length) {
|
||||||
|
buf = Buffer.allocUnsafe(2);
|
||||||
|
buf.writeUInt16BE(code, 0);
|
||||||
|
} else {
|
||||||
|
const length = Buffer.byteLength(data);
|
||||||
|
|
||||||
|
if (length > 123) {
|
||||||
|
throw new RangeError('The message must not be greater than 123 bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
buf = Buffer.allocUnsafe(2 + length);
|
||||||
|
buf.writeUInt16BE(code, 0);
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
buf.write(data, 2);
|
||||||
|
} else if (isUint8Array(data)) {
|
||||||
|
buf.set(data, 2);
|
||||||
|
} else {
|
||||||
|
throw new TypeError('Second argument must be a string or a Uint8Array');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
[kByteLength]: buf.length,
|
||||||
|
fin: true,
|
||||||
|
generateMask: this._generateMask,
|
||||||
|
mask,
|
||||||
|
maskBuffer: this._maskBuffer,
|
||||||
|
opcode: 0x08,
|
||||||
|
readOnly: false,
|
||||||
|
rsv1: false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this._state !== DEFAULT) {
|
||||||
|
this.enqueue([this.dispatch, buf, false, options, cb]);
|
||||||
|
} else {
|
||||||
|
this.sendFrame(Sender.frame(buf, options), cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a ping message to the other peer.
|
||||||
|
*
|
||||||
|
* @param {*} data The message to send
|
||||||
|
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
|
||||||
|
* @param {Function} [cb] Callback
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
ping(data, mask, cb) {
|
||||||
|
let byteLength;
|
||||||
|
let readOnly;
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
byteLength = Buffer.byteLength(data);
|
||||||
|
readOnly = false;
|
||||||
|
} else if (isBlob(data)) {
|
||||||
|
byteLength = data.size;
|
||||||
|
readOnly = false;
|
||||||
|
} else {
|
||||||
|
data = toBuffer(data);
|
||||||
|
byteLength = data.length;
|
||||||
|
readOnly = toBuffer.readOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byteLength > 125) {
|
||||||
|
throw new RangeError('The data size must not be greater than 125 bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
[kByteLength]: byteLength,
|
||||||
|
fin: true,
|
||||||
|
generateMask: this._generateMask,
|
||||||
|
mask,
|
||||||
|
maskBuffer: this._maskBuffer,
|
||||||
|
opcode: 0x09,
|
||||||
|
readOnly,
|
||||||
|
rsv1: false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isBlob(data)) {
|
||||||
|
if (this._state !== DEFAULT) {
|
||||||
|
this.enqueue([this.getBlobData, data, false, options, cb]);
|
||||||
|
} else {
|
||||||
|
this.getBlobData(data, false, options, cb);
|
||||||
|
}
|
||||||
|
} else if (this._state !== DEFAULT) {
|
||||||
|
this.enqueue([this.dispatch, data, false, options, cb]);
|
||||||
|
} else {
|
||||||
|
this.sendFrame(Sender.frame(data, options), cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a pong message to the other peer.
|
||||||
|
*
|
||||||
|
* @param {*} data The message to send
|
||||||
|
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
|
||||||
|
* @param {Function} [cb] Callback
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
pong(data, mask, cb) {
|
||||||
|
let byteLength;
|
||||||
|
let readOnly;
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
byteLength = Buffer.byteLength(data);
|
||||||
|
readOnly = false;
|
||||||
|
} else if (isBlob(data)) {
|
||||||
|
byteLength = data.size;
|
||||||
|
readOnly = false;
|
||||||
|
} else {
|
||||||
|
data = toBuffer(data);
|
||||||
|
byteLength = data.length;
|
||||||
|
readOnly = toBuffer.readOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byteLength > 125) {
|
||||||
|
throw new RangeError('The data size must not be greater than 125 bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
[kByteLength]: byteLength,
|
||||||
|
fin: true,
|
||||||
|
generateMask: this._generateMask,
|
||||||
|
mask,
|
||||||
|
maskBuffer: this._maskBuffer,
|
||||||
|
opcode: 0x0a,
|
||||||
|
readOnly,
|
||||||
|
rsv1: false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isBlob(data)) {
|
||||||
|
if (this._state !== DEFAULT) {
|
||||||
|
this.enqueue([this.getBlobData, data, false, options, cb]);
|
||||||
|
} else {
|
||||||
|
this.getBlobData(data, false, options, cb);
|
||||||
|
}
|
||||||
|
} else if (this._state !== DEFAULT) {
|
||||||
|
this.enqueue([this.dispatch, data, false, options, cb]);
|
||||||
|
} else {
|
||||||
|
this.sendFrame(Sender.frame(data, options), cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a data message to the other peer.
|
||||||
|
*
|
||||||
|
* @param {*} data The message to send
|
||||||
|
* @param {Object} options Options object
|
||||||
|
* @param {Boolean} [options.binary=false] Specifies whether `data` is binary
|
||||||
|
* or text
|
||||||
|
* @param {Boolean} [options.compress=false] Specifies whether or not to
|
||||||
|
* compress `data`
|
||||||
|
* @param {Boolean} [options.fin=false] Specifies whether the fragment is the
|
||||||
|
* last one
|
||||||
|
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
|
||||||
|
* `data`
|
||||||
|
* @param {Function} [cb] Callback
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
send(data, options, cb) {
|
||||||
|
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
|
||||||
|
let opcode = options.binary ? 2 : 1;
|
||||||
|
let rsv1 = options.compress;
|
||||||
|
|
||||||
|
let byteLength;
|
||||||
|
let readOnly;
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
byteLength = Buffer.byteLength(data);
|
||||||
|
readOnly = false;
|
||||||
|
} else if (isBlob(data)) {
|
||||||
|
byteLength = data.size;
|
||||||
|
readOnly = false;
|
||||||
|
} else {
|
||||||
|
data = toBuffer(data);
|
||||||
|
byteLength = data.length;
|
||||||
|
readOnly = toBuffer.readOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._firstFragment) {
|
||||||
|
this._firstFragment = false;
|
||||||
|
if (
|
||||||
|
rsv1 &&
|
||||||
|
perMessageDeflate &&
|
||||||
|
perMessageDeflate.params[
|
||||||
|
perMessageDeflate._isServer
|
||||||
|
? 'server_no_context_takeover'
|
||||||
|
: 'client_no_context_takeover'
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
rsv1 = byteLength >= perMessageDeflate._threshold;
|
||||||
|
}
|
||||||
|
this._compress = rsv1;
|
||||||
|
} else {
|
||||||
|
rsv1 = false;
|
||||||
|
opcode = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.fin) this._firstFragment = true;
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
[kByteLength]: byteLength,
|
||||||
|
fin: options.fin,
|
||||||
|
generateMask: this._generateMask,
|
||||||
|
mask: options.mask,
|
||||||
|
maskBuffer: this._maskBuffer,
|
||||||
|
opcode,
|
||||||
|
readOnly,
|
||||||
|
rsv1
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isBlob(data)) {
|
||||||
|
if (this._state !== DEFAULT) {
|
||||||
|
this.enqueue([this.getBlobData, data, this._compress, opts, cb]);
|
||||||
|
} else {
|
||||||
|
this.getBlobData(data, this._compress, opts, cb);
|
||||||
|
}
|
||||||
|
} else if (this._state !== DEFAULT) {
|
||||||
|
this.enqueue([this.dispatch, data, this._compress, opts, cb]);
|
||||||
|
} else {
|
||||||
|
this.dispatch(data, this._compress, opts, cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the contents of a blob as binary data.
|
||||||
|
*
|
||||||
|
* @param {Blob} blob The blob
|
||||||
|
* @param {Boolean} [compress=false] Specifies whether or not to compress
|
||||||
|
* the data
|
||||||
|
* @param {Object} options Options object
|
||||||
|
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
|
||||||
|
* FIN bit
|
||||||
|
* @param {Function} [options.generateMask] The function used to generate the
|
||||||
|
* masking key
|
||||||
|
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
|
||||||
|
* `data`
|
||||||
|
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
|
||||||
|
* key
|
||||||
|
* @param {Number} options.opcode The opcode
|
||||||
|
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
|
||||||
|
* modified
|
||||||
|
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
|
||||||
|
* RSV1 bit
|
||||||
|
* @param {Function} [cb] Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
getBlobData(blob, compress, options, cb) {
|
||||||
|
this._bufferedBytes += options[kByteLength];
|
||||||
|
this._state = GET_BLOB_DATA;
|
||||||
|
|
||||||
|
blob
|
||||||
|
.arrayBuffer()
|
||||||
|
.then((arrayBuffer) => {
|
||||||
|
if (this._socket.destroyed) {
|
||||||
|
const err = new Error(
|
||||||
|
'The socket was closed while the blob was being read'
|
||||||
|
);
|
||||||
|
|
||||||
|
//
|
||||||
|
// `callCallbacks` is called in the next tick to ensure that errors
|
||||||
|
// that might be thrown in the callbacks behave like errors thrown
|
||||||
|
// outside the promise chain.
|
||||||
|
//
|
||||||
|
process.nextTick(callCallbacks, this, err, cb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._bufferedBytes -= options[kByteLength];
|
||||||
|
const data = toBuffer(arrayBuffer);
|
||||||
|
|
||||||
|
if (!compress) {
|
||||||
|
this._state = DEFAULT;
|
||||||
|
this.sendFrame(Sender.frame(data, options), cb);
|
||||||
|
this.dequeue();
|
||||||
|
} else {
|
||||||
|
this.dispatch(data, compress, options, cb);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
//
|
||||||
|
// `onError` is called in the next tick for the same reason that
|
||||||
|
// `callCallbacks` above is.
|
||||||
|
//
|
||||||
|
process.nextTick(onError, this, err, cb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches a message.
|
||||||
|
*
|
||||||
|
* @param {(Buffer|String)} data The message to send
|
||||||
|
* @param {Boolean} [compress=false] Specifies whether or not to compress
|
||||||
|
* `data`
|
||||||
|
* @param {Object} options Options object
|
||||||
|
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
|
||||||
|
* FIN bit
|
||||||
|
* @param {Function} [options.generateMask] The function used to generate the
|
||||||
|
* masking key
|
||||||
|
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
|
||||||
|
* `data`
|
||||||
|
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
|
||||||
|
* key
|
||||||
|
* @param {Number} options.opcode The opcode
|
||||||
|
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
|
||||||
|
* modified
|
||||||
|
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
|
||||||
|
* RSV1 bit
|
||||||
|
* @param {Function} [cb] Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
dispatch(data, compress, options, cb) {
|
||||||
|
if (!compress) {
|
||||||
|
this.sendFrame(Sender.frame(data, options), cb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
|
||||||
|
|
||||||
|
this._bufferedBytes += options[kByteLength];
|
||||||
|
this._state = DEFLATING;
|
||||||
|
perMessageDeflate.compress(data, options.fin, (_, buf) => {
|
||||||
|
if (this._socket.destroyed) {
|
||||||
|
const err = new Error(
|
||||||
|
'The socket was closed while data was being compressed'
|
||||||
|
);
|
||||||
|
|
||||||
|
callCallbacks(this, err, cb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._bufferedBytes -= options[kByteLength];
|
||||||
|
this._state = DEFAULT;
|
||||||
|
options.readOnly = false;
|
||||||
|
this.sendFrame(Sender.frame(buf, options), cb);
|
||||||
|
this.dequeue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes queued send operations.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
dequeue() {
|
||||||
|
while (this._state === DEFAULT && this._queue.length) {
|
||||||
|
const params = this._queue.shift();
|
||||||
|
|
||||||
|
this._bufferedBytes -= params[3][kByteLength];
|
||||||
|
Reflect.apply(params[0], this, params.slice(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueues a send operation.
|
||||||
|
*
|
||||||
|
* @param {Array} params Send operation parameters.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
enqueue(params) {
|
||||||
|
this._bufferedBytes += params[3][kByteLength];
|
||||||
|
this._queue.push(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a frame.
|
||||||
|
*
|
||||||
|
* @param {(Buffer | String)[]} list The frame to send
|
||||||
|
* @param {Function} [cb] Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
sendFrame(list, cb) {
|
||||||
|
if (list.length === 2) {
|
||||||
|
this._socket.cork();
|
||||||
|
this._socket.write(list[0]);
|
||||||
|
this._socket.write(list[1], cb);
|
||||||
|
this._socket.uncork();
|
||||||
|
} else {
|
||||||
|
this._socket.write(list[0], cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Sender;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls queued callbacks with an error.
|
||||||
|
*
|
||||||
|
* @param {Sender} sender The `Sender` instance
|
||||||
|
* @param {Error} err The error to call the callbacks with
|
||||||
|
* @param {Function} [cb] The first callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function callCallbacks(sender, err, cb) {
|
||||||
|
if (typeof cb === 'function') cb(err);
|
||||||
|
|
||||||
|
for (let i = 0; i < sender._queue.length; i++) {
|
||||||
|
const params = sender._queue[i];
|
||||||
|
const callback = params[params.length - 1];
|
||||||
|
|
||||||
|
if (typeof callback === 'function') callback(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a `Sender` error.
|
||||||
|
*
|
||||||
|
* @param {Sender} sender The `Sender` instance
|
||||||
|
* @param {Error} err The error
|
||||||
|
* @param {Function} [cb] The first pending callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function onError(sender, err, cb) {
|
||||||
|
callCallbacks(sender, err, cb);
|
||||||
|
sender.onerror(err);
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^WebSocket$" }] */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const WebSocket = require('./websocket');
|
||||||
|
const { Duplex } = require('stream');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits the `'close'` event on a stream.
|
||||||
|
*
|
||||||
|
* @param {Duplex} stream The stream.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function emitClose(stream) {
|
||||||
|
stream.emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The listener of the `'end'` event.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function duplexOnEnd() {
|
||||||
|
if (!this.destroyed && this._writableState.finished) {
|
||||||
|
this.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The listener of the `'error'` event.
|
||||||
|
*
|
||||||
|
* @param {Error} err The error
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function duplexOnError(err) {
|
||||||
|
this.removeListener('error', duplexOnError);
|
||||||
|
this.destroy();
|
||||||
|
if (this.listenerCount('error') === 0) {
|
||||||
|
// Do not suppress the throwing behavior.
|
||||||
|
this.emit('error', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a `WebSocket` in a duplex stream.
|
||||||
|
*
|
||||||
|
* @param {WebSocket} ws The `WebSocket` to wrap
|
||||||
|
* @param {Object} [options] The options for the `Duplex` constructor
|
||||||
|
* @return {Duplex} The duplex stream
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function createWebSocketStream(ws, options) {
|
||||||
|
let terminateOnDestroy = true;
|
||||||
|
|
||||||
|
const duplex = new Duplex({
|
||||||
|
...options,
|
||||||
|
autoDestroy: false,
|
||||||
|
emitClose: false,
|
||||||
|
objectMode: false,
|
||||||
|
writableObjectMode: false
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', function message(msg, isBinary) {
|
||||||
|
const data =
|
||||||
|
!isBinary && duplex._readableState.objectMode ? msg.toString() : msg;
|
||||||
|
|
||||||
|
if (!duplex.push(data)) ws.pause();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.once('error', function error(err) {
|
||||||
|
if (duplex.destroyed) return;
|
||||||
|
|
||||||
|
// Prevent `ws.terminate()` from being called by `duplex._destroy()`.
|
||||||
|
//
|
||||||
|
// - If the `'error'` event is emitted before the `'open'` event, then
|
||||||
|
// `ws.terminate()` is a noop as no socket is assigned.
|
||||||
|
// - Otherwise, the error is re-emitted by the listener of the `'error'`
|
||||||
|
// event of the `Receiver` object. The listener already closes the
|
||||||
|
// connection by calling `ws.close()`. This allows a close frame to be
|
||||||
|
// sent to the other peer. If `ws.terminate()` is called right after this,
|
||||||
|
// then the close frame might not be sent.
|
||||||
|
terminateOnDestroy = false;
|
||||||
|
duplex.destroy(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.once('close', function close() {
|
||||||
|
if (duplex.destroyed) return;
|
||||||
|
|
||||||
|
duplex.push(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
duplex._destroy = function (err, callback) {
|
||||||
|
if (ws.readyState === ws.CLOSED) {
|
||||||
|
callback(err);
|
||||||
|
process.nextTick(emitClose, duplex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let called = false;
|
||||||
|
|
||||||
|
ws.once('error', function error(err) {
|
||||||
|
called = true;
|
||||||
|
callback(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.once('close', function close() {
|
||||||
|
if (!called) callback(err);
|
||||||
|
process.nextTick(emitClose, duplex);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (terminateOnDestroy) ws.terminate();
|
||||||
|
};
|
||||||
|
|
||||||
|
duplex._final = function (callback) {
|
||||||
|
if (ws.readyState === ws.CONNECTING) {
|
||||||
|
ws.once('open', function open() {
|
||||||
|
duplex._final(callback);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the value of the `_socket` property is `null` it means that `ws` is a
|
||||||
|
// client websocket and the handshake failed. In fact, when this happens, a
|
||||||
|
// socket is never assigned to the websocket. Wait for the `'error'` event
|
||||||
|
// that will be emitted by the websocket.
|
||||||
|
if (ws._socket === null) return;
|
||||||
|
|
||||||
|
if (ws._socket._writableState.finished) {
|
||||||
|
callback();
|
||||||
|
if (duplex._readableState.endEmitted) duplex.destroy();
|
||||||
|
} else {
|
||||||
|
ws._socket.once('finish', function finish() {
|
||||||
|
// `duplex` is not destroyed here because the `'end'` event will be
|
||||||
|
// emitted on `duplex` after this `'finish'` event. The EOF signaling
|
||||||
|
// `null` chunk is, in fact, pushed when the websocket emits `'close'`.
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
duplex._read = function () {
|
||||||
|
if (ws.isPaused) ws.resume();
|
||||||
|
};
|
||||||
|
|
||||||
|
duplex._write = function (chunk, encoding, callback) {
|
||||||
|
if (ws.readyState === ws.CONNECTING) {
|
||||||
|
ws.once('open', function open() {
|
||||||
|
duplex._write(chunk, encoding, callback);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(chunk, callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
duplex.on('end', duplexOnEnd);
|
||||||
|
duplex.on('error', duplexOnError);
|
||||||
|
return duplex;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createWebSocketStream;
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { tokenChars } = require('./validation');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the `Sec-WebSocket-Protocol` header into a set of subprotocol names.
|
||||||
|
*
|
||||||
|
* @param {String} header The field value of the header
|
||||||
|
* @return {Set} The subprotocol names
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function parse(header) {
|
||||||
|
const protocols = new Set();
|
||||||
|
let start = -1;
|
||||||
|
let end = -1;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
for (i; i < header.length; i++) {
|
||||||
|
const code = header.charCodeAt(i);
|
||||||
|
|
||||||
|
if (end === -1 && tokenChars[code] === 1) {
|
||||||
|
if (start === -1) start = i;
|
||||||
|
} else if (
|
||||||
|
i !== 0 &&
|
||||||
|
(code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
|
||||||
|
) {
|
||||||
|
if (end === -1 && start !== -1) end = i;
|
||||||
|
} else if (code === 0x2c /* ',' */) {
|
||||||
|
if (start === -1) {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end === -1) end = i;
|
||||||
|
|
||||||
|
const protocol = header.slice(start, end);
|
||||||
|
|
||||||
|
if (protocols.has(protocol)) {
|
||||||
|
throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protocols.add(protocol);
|
||||||
|
start = end = -1;
|
||||||
|
} else {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start === -1 || end !== -1) {
|
||||||
|
throw new SyntaxError('Unexpected end of input');
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = header.slice(start, i);
|
||||||
|
|
||||||
|
if (protocols.has(protocol)) {
|
||||||
|
throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protocols.add(protocol);
|
||||||
|
return protocols;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parse };
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { isUtf8 } = require('buffer');
|
||||||
|
|
||||||
|
const { hasBlob } = require('./constants');
|
||||||
|
|
||||||
|
//
|
||||||
|
// Allowed token characters:
|
||||||
|
//
|
||||||
|
// '!', '#', '$', '%', '&', ''', '*', '+', '-',
|
||||||
|
// '.', 0-9, A-Z, '^', '_', '`', a-z, '|', '~'
|
||||||
|
//
|
||||||
|
// tokenChars[32] === 0 // ' '
|
||||||
|
// tokenChars[33] === 1 // '!'
|
||||||
|
// tokenChars[34] === 0 // '"'
|
||||||
|
// ...
|
||||||
|
//
|
||||||
|
// prettier-ignore
|
||||||
|
const tokenChars = [
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31
|
||||||
|
0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32 - 47
|
||||||
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63
|
||||||
|
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79
|
||||||
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80 - 95
|
||||||
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111
|
||||||
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0 // 112 - 127
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a status code is allowed in a close frame.
|
||||||
|
*
|
||||||
|
* @param {Number} code The status code
|
||||||
|
* @return {Boolean} `true` if the status code is valid, else `false`
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function isValidStatusCode(code) {
|
||||||
|
return (
|
||||||
|
(code >= 1000 &&
|
||||||
|
code <= 1014 &&
|
||||||
|
code !== 1004 &&
|
||||||
|
code !== 1005 &&
|
||||||
|
code !== 1006) ||
|
||||||
|
(code >= 3000 && code <= 4999)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a given buffer contains only correct UTF-8.
|
||||||
|
* Ported from https://www.cl.cam.ac.uk/%7Emgk25/ucs/utf8_check.c by
|
||||||
|
* Markus Kuhn.
|
||||||
|
*
|
||||||
|
* @param {Buffer} buf The buffer to check
|
||||||
|
* @return {Boolean} `true` if `buf` contains only correct UTF-8, else `false`
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function _isValidUTF8(buf) {
|
||||||
|
const len = buf.length;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < len) {
|
||||||
|
if ((buf[i] & 0x80) === 0) {
|
||||||
|
// 0xxxxxxx
|
||||||
|
i++;
|
||||||
|
} else if ((buf[i] & 0xe0) === 0xc0) {
|
||||||
|
// 110xxxxx 10xxxxxx
|
||||||
|
if (
|
||||||
|
i + 1 === len ||
|
||||||
|
(buf[i + 1] & 0xc0) !== 0x80 ||
|
||||||
|
(buf[i] & 0xfe) === 0xc0 // Overlong
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 2;
|
||||||
|
} else if ((buf[i] & 0xf0) === 0xe0) {
|
||||||
|
// 1110xxxx 10xxxxxx 10xxxxxx
|
||||||
|
if (
|
||||||
|
i + 2 >= len ||
|
||||||
|
(buf[i + 1] & 0xc0) !== 0x80 ||
|
||||||
|
(buf[i + 2] & 0xc0) !== 0x80 ||
|
||||||
|
(buf[i] === 0xe0 && (buf[i + 1] & 0xe0) === 0x80) || // Overlong
|
||||||
|
(buf[i] === 0xed && (buf[i + 1] & 0xe0) === 0xa0) // Surrogate (U+D800 - U+DFFF)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 3;
|
||||||
|
} else if ((buf[i] & 0xf8) === 0xf0) {
|
||||||
|
// 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
|
||||||
|
if (
|
||||||
|
i + 3 >= len ||
|
||||||
|
(buf[i + 1] & 0xc0) !== 0x80 ||
|
||||||
|
(buf[i + 2] & 0xc0) !== 0x80 ||
|
||||||
|
(buf[i + 3] & 0xc0) !== 0x80 ||
|
||||||
|
(buf[i] === 0xf0 && (buf[i + 1] & 0xf0) === 0x80) || // Overlong
|
||||||
|
(buf[i] === 0xf4 && buf[i + 1] > 0x8f) ||
|
||||||
|
buf[i] > 0xf4 // > U+10FFFF
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 4;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a value is a `Blob`.
|
||||||
|
*
|
||||||
|
* @param {*} value The value to be tested
|
||||||
|
* @return {Boolean} `true` if `value` is a `Blob`, else `false`
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function isBlob(value) {
|
||||||
|
return (
|
||||||
|
hasBlob &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
typeof value.arrayBuffer === 'function' &&
|
||||||
|
typeof value.type === 'string' &&
|
||||||
|
typeof value.stream === 'function' &&
|
||||||
|
(value[Symbol.toStringTag] === 'Blob' ||
|
||||||
|
value[Symbol.toStringTag] === 'File')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isBlob,
|
||||||
|
isValidStatusCode,
|
||||||
|
isValidUTF8: _isValidUTF8,
|
||||||
|
tokenChars
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isUtf8) {
|
||||||
|
module.exports.isValidUTF8 = function (buf) {
|
||||||
|
return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf);
|
||||||
|
};
|
||||||
|
} /* istanbul ignore else */ else if (!process.env.WS_NO_UTF_8_VALIDATE) {
|
||||||
|
try {
|
||||||
|
const isValidUTF8 = require('utf-8-validate');
|
||||||
|
|
||||||
|
module.exports.isValidUTF8 = function (buf) {
|
||||||
|
return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf);
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// Continue regardless of the error.
|
||||||
|
}
|
||||||
|
}
|
||||||
+562
@@ -0,0 +1,562 @@
|
|||||||
|
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$", "caughtErrors": "none" }] */
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const http = require('http');
|
||||||
|
const { Duplex } = require('stream');
|
||||||
|
const { createHash } = require('crypto');
|
||||||
|
|
||||||
|
const extension = require('./extension');
|
||||||
|
const PerMessageDeflate = require('./permessage-deflate');
|
||||||
|
const subprotocol = require('./subprotocol');
|
||||||
|
const WebSocket = require('./websocket');
|
||||||
|
const { CLOSE_TIMEOUT, GUID, kWebSocket } = require('./constants');
|
||||||
|
|
||||||
|
const keyRegex = /^[+/0-9A-Za-z]{22}==$/;
|
||||||
|
|
||||||
|
const RUNNING = 0;
|
||||||
|
const CLOSING = 1;
|
||||||
|
const CLOSED = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class representing a WebSocket server.
|
||||||
|
*
|
||||||
|
* @extends EventEmitter
|
||||||
|
*/
|
||||||
|
class WebSocketServer extends EventEmitter {
|
||||||
|
/**
|
||||||
|
* Create a `WebSocketServer` instance.
|
||||||
|
*
|
||||||
|
* @param {Object} options Configuration options
|
||||||
|
* @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether
|
||||||
|
* any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
|
||||||
|
* multiple times in the same tick
|
||||||
|
* @param {Boolean} [options.autoPong=true] Specifies whether or not to
|
||||||
|
* automatically send a pong in response to a ping
|
||||||
|
* @param {Number} [options.backlog=511] The maximum length of the queue of
|
||||||
|
* pending connections
|
||||||
|
* @param {Boolean} [options.clientTracking=true] Specifies whether or not to
|
||||||
|
* track clients
|
||||||
|
* @param {Number} [options.closeTimeout=30000] Duration in milliseconds to
|
||||||
|
* wait for the closing handshake to finish after `websocket.close()` is
|
||||||
|
* called
|
||||||
|
* @param {Function} [options.handleProtocols] A hook to handle protocols
|
||||||
|
* @param {String} [options.host] The hostname where to bind the server
|
||||||
|
* @param {Number} [options.maxBufferedChunks=262144] The maximum number of
|
||||||
|
* buffered data chunks
|
||||||
|
* @param {Number} [options.maxFragments=16384] The maximum number of message
|
||||||
|
* fragments
|
||||||
|
* @param {Number} [options.maxPayload=104857600] The maximum allowed message
|
||||||
|
* size
|
||||||
|
* @param {Boolean} [options.noServer=false] Enable no server mode
|
||||||
|
* @param {String} [options.path] Accept only connections matching this path
|
||||||
|
* @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable
|
||||||
|
* permessage-deflate
|
||||||
|
* @param {Number} [options.port] The port where to bind the server
|
||||||
|
* @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S
|
||||||
|
* server to use
|
||||||
|
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
|
||||||
|
* not to skip UTF-8 validation for text and close messages
|
||||||
|
* @param {Function} [options.verifyClient] A hook to reject connections
|
||||||
|
* @param {Function} [options.WebSocket=WebSocket] Specifies the `WebSocket`
|
||||||
|
* class to use. It must be the `WebSocket` class or class that extends it
|
||||||
|
* @param {Function} [callback] A listener for the `listening` event
|
||||||
|
*/
|
||||||
|
constructor(options, callback) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
options = {
|
||||||
|
allowSynchronousEvents: true,
|
||||||
|
autoPong: true,
|
||||||
|
maxBufferedChunks: 256 * 1024,
|
||||||
|
maxFragments: 16 * 1024,
|
||||||
|
maxPayload: 100 * 1024 * 1024,
|
||||||
|
skipUTF8Validation: false,
|
||||||
|
perMessageDeflate: false,
|
||||||
|
handleProtocols: null,
|
||||||
|
clientTracking: true,
|
||||||
|
closeTimeout: CLOSE_TIMEOUT,
|
||||||
|
verifyClient: null,
|
||||||
|
noServer: false,
|
||||||
|
backlog: null, // use default (511 as implemented in net.js)
|
||||||
|
server: null,
|
||||||
|
host: null,
|
||||||
|
path: null,
|
||||||
|
port: null,
|
||||||
|
WebSocket,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
(options.port == null && !options.server && !options.noServer) ||
|
||||||
|
(options.port != null && (options.server || options.noServer)) ||
|
||||||
|
(options.server && options.noServer)
|
||||||
|
) {
|
||||||
|
throw new TypeError(
|
||||||
|
'One and only one of the "port", "server", or "noServer" options ' +
|
||||||
|
'must be specified'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.port != null) {
|
||||||
|
this._server = http.createServer((req, res) => {
|
||||||
|
const body = http.STATUS_CODES[426];
|
||||||
|
|
||||||
|
res.writeHead(426, {
|
||||||
|
'Content-Length': body.length,
|
||||||
|
'Content-Type': 'text/plain'
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
});
|
||||||
|
this._server.listen(
|
||||||
|
options.port,
|
||||||
|
options.host,
|
||||||
|
options.backlog,
|
||||||
|
callback
|
||||||
|
);
|
||||||
|
} else if (options.server) {
|
||||||
|
this._server = options.server;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._server) {
|
||||||
|
const emitConnection = this.emit.bind(this, 'connection');
|
||||||
|
|
||||||
|
this._removeListeners = addListeners(this._server, {
|
||||||
|
listening: this.emit.bind(this, 'listening'),
|
||||||
|
error: this.emit.bind(this, 'error'),
|
||||||
|
upgrade: (req, socket, head) => {
|
||||||
|
this.handleUpgrade(req, socket, head, emitConnection);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.perMessageDeflate === true) options.perMessageDeflate = {};
|
||||||
|
if (options.clientTracking) {
|
||||||
|
this.clients = new Set();
|
||||||
|
this._shouldEmitClose = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options = options;
|
||||||
|
this._state = RUNNING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the bound address, the address family name, and port of the server
|
||||||
|
* as reported by the operating system if listening on an IP socket.
|
||||||
|
* If the server is listening on a pipe or UNIX domain socket, the name is
|
||||||
|
* returned as a string.
|
||||||
|
*
|
||||||
|
* @return {(Object|String|null)} The address of the server
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
address() {
|
||||||
|
if (this.options.noServer) {
|
||||||
|
throw new Error('The server is operating in "noServer" mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._server) return null;
|
||||||
|
return this._server.address();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the server from accepting new connections and emit the `'close'` event
|
||||||
|
* when all existing connections are closed.
|
||||||
|
*
|
||||||
|
* @param {Function} [cb] A one-time listener for the `'close'` event
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
close(cb) {
|
||||||
|
if (this._state === CLOSED) {
|
||||||
|
if (cb) {
|
||||||
|
this.once('close', () => {
|
||||||
|
cb(new Error('The server is not running'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
process.nextTick(emitClose, this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cb) this.once('close', cb);
|
||||||
|
|
||||||
|
if (this._state === CLOSING) return;
|
||||||
|
this._state = CLOSING;
|
||||||
|
|
||||||
|
if (this.options.noServer || this.options.server) {
|
||||||
|
if (this._server) {
|
||||||
|
this._removeListeners();
|
||||||
|
this._removeListeners = this._server = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.clients) {
|
||||||
|
if (!this.clients.size) {
|
||||||
|
process.nextTick(emitClose, this);
|
||||||
|
} else {
|
||||||
|
this._shouldEmitClose = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
process.nextTick(emitClose, this);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const server = this._server;
|
||||||
|
|
||||||
|
this._removeListeners();
|
||||||
|
this._removeListeners = this._server = null;
|
||||||
|
|
||||||
|
//
|
||||||
|
// The HTTP/S server was created internally. Close it, and rely on its
|
||||||
|
// `'close'` event.
|
||||||
|
//
|
||||||
|
server.close(() => {
|
||||||
|
emitClose(this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See if a given request should be handled by this server instance.
|
||||||
|
*
|
||||||
|
* @param {http.IncomingMessage} req Request object to inspect
|
||||||
|
* @return {Boolean} `true` if the request is valid, else `false`
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
shouldHandle(req) {
|
||||||
|
if (this.options.path) {
|
||||||
|
const index = req.url.indexOf('?');
|
||||||
|
const pathname = index !== -1 ? req.url.slice(0, index) : req.url;
|
||||||
|
|
||||||
|
if (pathname !== this.options.path) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a HTTP Upgrade request.
|
||||||
|
*
|
||||||
|
* @param {http.IncomingMessage} req The request object
|
||||||
|
* @param {Duplex} socket The network socket between the server and client
|
||||||
|
* @param {Buffer} head The first packet of the upgraded stream
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
handleUpgrade(req, socket, head, cb) {
|
||||||
|
socket.on('error', socketOnError);
|
||||||
|
|
||||||
|
const key = req.headers['sec-websocket-key'];
|
||||||
|
const upgrade = req.headers.upgrade;
|
||||||
|
const version = +req.headers['sec-websocket-version'];
|
||||||
|
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
const message = 'Invalid HTTP method';
|
||||||
|
abortHandshakeOrEmitwsClientError(this, req, socket, 405, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') {
|
||||||
|
const message = 'Invalid Upgrade header';
|
||||||
|
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === undefined || !keyRegex.test(key)) {
|
||||||
|
const message = 'Missing or invalid Sec-WebSocket-Key header';
|
||||||
|
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version !== 13 && version !== 8) {
|
||||||
|
const message = 'Missing or invalid Sec-WebSocket-Version header';
|
||||||
|
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message, {
|
||||||
|
'Sec-WebSocket-Version': '13, 8'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.shouldHandle(req)) {
|
||||||
|
abortHandshake(socket, 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secWebSocketProtocol = req.headers['sec-websocket-protocol'];
|
||||||
|
let protocols = new Set();
|
||||||
|
|
||||||
|
if (secWebSocketProtocol !== undefined) {
|
||||||
|
try {
|
||||||
|
protocols = subprotocol.parse(secWebSocketProtocol);
|
||||||
|
} catch (err) {
|
||||||
|
const message = 'Invalid Sec-WebSocket-Protocol header';
|
||||||
|
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const secWebSocketExtensions = req.headers['sec-websocket-extensions'];
|
||||||
|
const extensions = {};
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.options.perMessageDeflate &&
|
||||||
|
secWebSocketExtensions !== undefined
|
||||||
|
) {
|
||||||
|
const perMessageDeflate = new PerMessageDeflate({
|
||||||
|
...this.options.perMessageDeflate,
|
||||||
|
isServer: true,
|
||||||
|
maxPayload: this.options.maxPayload
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const offers = extension.parse(secWebSocketExtensions);
|
||||||
|
|
||||||
|
if (offers[PerMessageDeflate.extensionName]) {
|
||||||
|
perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]);
|
||||||
|
extensions[PerMessageDeflate.extensionName] = perMessageDeflate;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
'Invalid or unacceptable Sec-WebSocket-Extensions header';
|
||||||
|
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Optionally call external client verification handler.
|
||||||
|
//
|
||||||
|
if (this.options.verifyClient) {
|
||||||
|
const info = {
|
||||||
|
origin:
|
||||||
|
req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`],
|
||||||
|
secure: !!(req.socket.authorized || req.socket.encrypted),
|
||||||
|
req
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.options.verifyClient.length === 2) {
|
||||||
|
this.options.verifyClient(info, (verified, code, message, headers) => {
|
||||||
|
if (!verified) {
|
||||||
|
return abortHandshake(socket, code || 401, message, headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.completeUpgrade(
|
||||||
|
extensions,
|
||||||
|
key,
|
||||||
|
protocols,
|
||||||
|
req,
|
||||||
|
socket,
|
||||||
|
head,
|
||||||
|
cb
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.options.verifyClient(info)) return abortHandshake(socket, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.completeUpgrade(extensions, key, protocols, req, socket, head, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upgrade the connection to WebSocket.
|
||||||
|
*
|
||||||
|
* @param {Object} extensions The accepted extensions
|
||||||
|
* @param {String} key The value of the `Sec-WebSocket-Key` header
|
||||||
|
* @param {Set} protocols The subprotocols
|
||||||
|
* @param {http.IncomingMessage} req The request object
|
||||||
|
* @param {Duplex} socket The network socket between the server and client
|
||||||
|
* @param {Buffer} head The first packet of the upgraded stream
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @throws {Error} If called more than once with the same socket
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
completeUpgrade(extensions, key, protocols, req, socket, head, cb) {
|
||||||
|
//
|
||||||
|
// Destroy the socket if the client has already sent a FIN packet.
|
||||||
|
//
|
||||||
|
if (!socket.readable || !socket.writable) return socket.destroy();
|
||||||
|
|
||||||
|
if (socket[kWebSocket]) {
|
||||||
|
throw new Error(
|
||||||
|
'server.handleUpgrade() was called more than once with the same ' +
|
||||||
|
'socket, possibly due to a misconfiguration'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._state > RUNNING) return abortHandshake(socket, 503);
|
||||||
|
|
||||||
|
const digest = createHash('sha1')
|
||||||
|
.update(key + GUID)
|
||||||
|
.digest('base64');
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
'HTTP/1.1 101 Switching Protocols',
|
||||||
|
'Upgrade: websocket',
|
||||||
|
'Connection: Upgrade',
|
||||||
|
`Sec-WebSocket-Accept: ${digest}`
|
||||||
|
];
|
||||||
|
|
||||||
|
const ws = new this.options.WebSocket(null, undefined, this.options);
|
||||||
|
|
||||||
|
if (protocols.size) {
|
||||||
|
//
|
||||||
|
// Optionally call external protocol selection handler.
|
||||||
|
//
|
||||||
|
const protocol = this.options.handleProtocols
|
||||||
|
? this.options.handleProtocols(protocols, req)
|
||||||
|
: protocols.values().next().value;
|
||||||
|
|
||||||
|
if (protocol) {
|
||||||
|
headers.push(`Sec-WebSocket-Protocol: ${protocol}`);
|
||||||
|
ws._protocol = protocol;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extensions[PerMessageDeflate.extensionName]) {
|
||||||
|
const params = extensions[PerMessageDeflate.extensionName].params;
|
||||||
|
const value = extension.format({
|
||||||
|
[PerMessageDeflate.extensionName]: [params]
|
||||||
|
});
|
||||||
|
headers.push(`Sec-WebSocket-Extensions: ${value}`);
|
||||||
|
ws._extensions = extensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Allow external modification/inspection of handshake headers.
|
||||||
|
//
|
||||||
|
this.emit('headers', headers, req);
|
||||||
|
|
||||||
|
socket.write(headers.concat('\r\n').join('\r\n'));
|
||||||
|
socket.removeListener('error', socketOnError);
|
||||||
|
|
||||||
|
ws.setSocket(socket, head, {
|
||||||
|
allowSynchronousEvents: this.options.allowSynchronousEvents,
|
||||||
|
maxBufferedChunks: this.options.maxBufferedChunks,
|
||||||
|
maxFragments: this.options.maxFragments,
|
||||||
|
maxPayload: this.options.maxPayload,
|
||||||
|
skipUTF8Validation: this.options.skipUTF8Validation
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.clients) {
|
||||||
|
this.clients.add(ws);
|
||||||
|
ws.on('close', () => {
|
||||||
|
this.clients.delete(ws);
|
||||||
|
|
||||||
|
if (this._shouldEmitClose && !this.clients.size) {
|
||||||
|
process.nextTick(emitClose, this);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(ws, req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = WebSocketServer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add event listeners on an `EventEmitter` using a map of <event, listener>
|
||||||
|
* pairs.
|
||||||
|
*
|
||||||
|
* @param {EventEmitter} server The event emitter
|
||||||
|
* @param {Object.<String, Function>} map The listeners to add
|
||||||
|
* @return {Function} A function that will remove the added listeners when
|
||||||
|
* called
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function addListeners(server, map) {
|
||||||
|
for (const event of Object.keys(map)) server.on(event, map[event]);
|
||||||
|
|
||||||
|
return function removeListeners() {
|
||||||
|
for (const event of Object.keys(map)) {
|
||||||
|
server.removeListener(event, map[event]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a `'close'` event on an `EventEmitter`.
|
||||||
|
*
|
||||||
|
* @param {EventEmitter} server The event emitter
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function emitClose(server) {
|
||||||
|
server._state = CLOSED;
|
||||||
|
server.emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle socket errors.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function socketOnError() {
|
||||||
|
this.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the connection when preconditions are not fulfilled.
|
||||||
|
*
|
||||||
|
* @param {Duplex} socket The socket of the upgrade request
|
||||||
|
* @param {Number} code The HTTP response status code
|
||||||
|
* @param {String} [message] The HTTP response body
|
||||||
|
* @param {Object} [headers] Additional HTTP response headers
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function abortHandshake(socket, code, message, headers) {
|
||||||
|
//
|
||||||
|
// The socket is writable unless the user destroyed or ended it before calling
|
||||||
|
// `server.handleUpgrade()` or in the `verifyClient` function, which is a user
|
||||||
|
// error. Handling this does not make much sense as the worst that can happen
|
||||||
|
// is that some of the data written by the user might be discarded due to the
|
||||||
|
// call to `socket.end()` below, which triggers an `'error'` event that in
|
||||||
|
// turn causes the socket to be destroyed.
|
||||||
|
//
|
||||||
|
message = message || http.STATUS_CODES[code];
|
||||||
|
headers = {
|
||||||
|
Connection: 'close',
|
||||||
|
'Content-Type': 'text/html',
|
||||||
|
'Content-Length': Buffer.byteLength(message),
|
||||||
|
...headers
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.once('finish', socket.destroy);
|
||||||
|
|
||||||
|
socket.end(
|
||||||
|
`HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` +
|
||||||
|
Object.keys(headers)
|
||||||
|
.map((h) => `${h}: ${headers[h]}`)
|
||||||
|
.join('\r\n') +
|
||||||
|
'\r\n\r\n' +
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a `'wsClientError'` event on a `WebSocketServer` if there is at least
|
||||||
|
* one listener for it, otherwise call `abortHandshake()`.
|
||||||
|
*
|
||||||
|
* @param {WebSocketServer} server The WebSocket server
|
||||||
|
* @param {http.IncomingMessage} req The request object
|
||||||
|
* @param {Duplex} socket The socket of the upgrade request
|
||||||
|
* @param {Number} code The HTTP response status code
|
||||||
|
* @param {String} message The HTTP response body
|
||||||
|
* @param {Object} [headers] The HTTP response headers
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function abortHandshakeOrEmitwsClientError(
|
||||||
|
server,
|
||||||
|
req,
|
||||||
|
socket,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
headers
|
||||||
|
) {
|
||||||
|
if (server.listenerCount('wsClientError')) {
|
||||||
|
const err = new Error(message);
|
||||||
|
Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError);
|
||||||
|
|
||||||
|
server.emit('wsClientError', err, socket, req);
|
||||||
|
} else {
|
||||||
|
abortHandshake(socket, code, message, headers);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1407
File diff suppressed because it is too large
Load Diff
+74
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"name": "ws",
|
||||||
|
"version": "8.21.3",
|
||||||
|
"description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
|
||||||
|
"keywords": [
|
||||||
|
"HyBi",
|
||||||
|
"Push",
|
||||||
|
"RFC-6455",
|
||||||
|
"WebSocket",
|
||||||
|
"WebSockets",
|
||||||
|
"real-time"
|
||||||
|
],
|
||||||
|
"homepage": "https://github.com/websockets/ws",
|
||||||
|
"bugs": "https://github.com/websockets/ws/issues",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/websockets/ws.git"
|
||||||
|
},
|
||||||
|
"author": "Einar Otto Stangvik <einaros@gmail.com> (http://2x.io)",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "index.js",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"browser": "./browser.js",
|
||||||
|
"import": "./wrapper.mjs",
|
||||||
|
"require": "./index.js"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"browser": "browser.js",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"browser.js",
|
||||||
|
"index.js",
|
||||||
|
"lib/*.js",
|
||||||
|
"wrapper.mjs"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js",
|
||||||
|
"integration": "mocha --throw-deprecation test/*.integration.js",
|
||||||
|
"lint": "eslint . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\""
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"benchmark": "^2.1.4",
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"eslint": "^10.0.1",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
|
"globals": "^17.0.0",
|
||||||
|
"mocha": "^8.4.0",
|
||||||
|
"nyc": "^15.0.0",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"utf-8-validate": "^6.0.0"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"bufferutil": true,
|
||||||
|
"utf-8-validate": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
import createWebSocketStream from './lib/stream.js';
|
||||||
|
import extension from './lib/extension.js';
|
||||||
|
import PerMessageDeflate from './lib/permessage-deflate.js';
|
||||||
|
import Receiver from './lib/receiver.js';
|
||||||
|
import Sender from './lib/sender.js';
|
||||||
|
import subprotocol from './lib/subprotocol.js';
|
||||||
|
import WebSocket from './lib/websocket.js';
|
||||||
|
import WebSocketServer from './lib/websocket-server.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createWebSocketStream,
|
||||||
|
extension,
|
||||||
|
PerMessageDeflate,
|
||||||
|
Receiver,
|
||||||
|
Sender,
|
||||||
|
subprotocol,
|
||||||
|
WebSocket,
|
||||||
|
WebSocketServer
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WebSocket;
|
||||||
Generated
+23
-1
@@ -12,7 +12,8 @@
|
|||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"mysql2": "^3.24.2"
|
"mysql2": "^3.24.2",
|
||||||
|
"ws": "^8.21.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.14"
|
"nodemon": "^3.1.14"
|
||||||
@@ -1477,6 +1478,27 @@
|
|||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,8 @@
|
|||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"mysql2": "^3.24.2"
|
"mysql2": "^3.24.2",
|
||||||
|
"ws": "^8.21.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.14"
|
"nodemon": "^3.1.14"
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ router.post("/register", (req, res) => {
|
|||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
code: 500,
|
code: 500,
|
||||||
message: "注册失败",
|
message: "注册失败",
|
||||||
|
data: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require("../db/mysql");
|
||||||
|
const authMiddleware = require("../middleware/authMiddleware");
|
||||||
|
|
||||||
|
// 获取最近联系人的最后一条聊天消息
|
||||||
|
router.get(
|
||||||
|
"/recentChats",
|
||||||
|
authMiddleware,
|
||||||
|
(req, res) => {
|
||||||
|
const currentUserName = req.user.username;
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
contact_name,
|
||||||
|
content,
|
||||||
|
created_at
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN sender_name = ? THEN receiver_name
|
||||||
|
ELSE sender_name
|
||||||
|
END AS contact_name,
|
||||||
|
content,
|
||||||
|
created_at,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY
|
||||||
|
CASE
|
||||||
|
WHEN sender_name = ? THEN receiver_name
|
||||||
|
ELSE sender_name
|
||||||
|
END
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
) AS rn
|
||||||
|
FROM chat_mysql.messages
|
||||||
|
WHERE sender_name = ?
|
||||||
|
OR receiver_name = ?
|
||||||
|
) t
|
||||||
|
WHERE rn = 1
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
`;
|
||||||
|
db.query(sql, [currentUserName, currentUserName, currentUserName, currentUserName], (error, results) => {
|
||||||
|
if (error) {
|
||||||
|
console.error("数据库查询失败:", error);
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
code: 500,
|
||||||
|
message: "查询失败",
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (results.length > 0) {
|
||||||
|
res.json({
|
||||||
|
code: 200,
|
||||||
|
message: "查询成功",
|
||||||
|
data: {
|
||||||
|
recentChats: results.map(message => ({
|
||||||
|
contactName: message.contact_name,
|
||||||
|
content: message.content,
|
||||||
|
createdAt: message.created_at
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json({
|
||||||
|
code: 401,
|
||||||
|
message: "未找到联系人消息",
|
||||||
|
data: {
|
||||||
|
recentChats: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取某个联系人的最近n条聊天消息,分页获取
|
||||||
|
router.post(
|
||||||
|
"/contactMessagesInfo",
|
||||||
|
authMiddleware,
|
||||||
|
(req, res) => {
|
||||||
|
const currentUserName = req.user.username;
|
||||||
|
const { contactName, limit, offset } = req.body;
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
sender_name,
|
||||||
|
receiver_name,
|
||||||
|
content,
|
||||||
|
created_at
|
||||||
|
FROM chat_mysql.messages
|
||||||
|
WHERE (sender_name = ? OR receiver_name = ?) AND (sender_name = ? OR receiver_name = ?)
|
||||||
|
ORDER BY created_at DESC LIMIT ? OFFSET ?;
|
||||||
|
`;
|
||||||
|
db.query(sql, [currentUserName, currentUserName, contactName, contactName, limit, offset], (error, results) => {
|
||||||
|
if (error) {
|
||||||
|
console.error("数据库查询失败:", error);
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
code: 500,
|
||||||
|
message: "查询失败",
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (results.length > 0) {
|
||||||
|
res.json({
|
||||||
|
code: 200,
|
||||||
|
message: "查询成功",
|
||||||
|
data: {
|
||||||
|
messages: results.map(message => ({
|
||||||
|
messageId: message.id,
|
||||||
|
senderName: message.sender_name,
|
||||||
|
receiverName: message.receiver_name,
|
||||||
|
content: message.content,
|
||||||
|
createdAt: message.created_at
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json({
|
||||||
|
code: 401,
|
||||||
|
message: "未找到联系人消息",
|
||||||
|
data: {
|
||||||
|
messages: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 写入聊天消息
|
||||||
|
router.post(
|
||||||
|
"/sendMessage",
|
||||||
|
authMiddleware,
|
||||||
|
(req, res) => {
|
||||||
|
const message = req.body;
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO messages
|
||||||
|
(sender_name, receiver_name, content)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.query(sql, [
|
||||||
|
message.senderName,
|
||||||
|
message.receiverName,
|
||||||
|
message.content
|
||||||
|
], (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
code: 500,
|
||||||
|
message: "发送消息失败",
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
code: 200,
|
||||||
|
message: "发送消息成功",
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+4
-87
@@ -11,6 +11,7 @@ router.get(
|
|||||||
(req, res) => {
|
(req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
code: 200,
|
code: 200,
|
||||||
|
message: "查询成功",
|
||||||
data: {
|
data: {
|
||||||
username: req.user.username
|
username: req.user.username
|
||||||
}
|
}
|
||||||
@@ -30,7 +31,8 @@ router.get(
|
|||||||
FROM chat_mysql.contacts c
|
FROM chat_mysql.contacts c
|
||||||
JOIN chat_mysql.users u
|
JOIN chat_mysql.users u
|
||||||
ON c.contact_name = u.username
|
ON c.contact_name = u.username
|
||||||
WHERE c.user_name = ?;
|
WHERE c.user_name = ?
|
||||||
|
ORDER BY contact_name ASC;;
|
||||||
`;
|
`;
|
||||||
db.query(sql, [req.user.username], (error, results) => {
|
db.query(sql, [req.user.username], (error, results) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -39,6 +41,7 @@ router.get(
|
|||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
code: 500,
|
code: 500,
|
||||||
message: "查询失败",
|
message: "查询失败",
|
||||||
|
data: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
@@ -65,90 +68,4 @@ router.get(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// 获取所有聊天消息
|
|
||||||
router.get(
|
|
||||||
"/messagesInfo",
|
|
||||||
authMiddleware,
|
|
||||||
(req, res) => {
|
|
||||||
const currentUserName = req.user.username;
|
|
||||||
const sql = `
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
sender_name,
|
|
||||||
receiver_name,
|
|
||||||
content,
|
|
||||||
created_at
|
|
||||||
FROM chat_mysql.messages
|
|
||||||
WHERE sender_name = ? OR receiver_name = ?
|
|
||||||
ORDER BY created_at ASC;
|
|
||||||
`;
|
|
||||||
db.query(sql, [currentUserName, currentUserName], (error, results) => {
|
|
||||||
if (error) {
|
|
||||||
console.error("数据库查询失败:", error);
|
|
||||||
|
|
||||||
return res.status(500).json({
|
|
||||||
code: 500,
|
|
||||||
message: "查询失败",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (results.length > 0) {
|
|
||||||
res.json({
|
|
||||||
code: 200,
|
|
||||||
message: "查询成功",
|
|
||||||
data: {
|
|
||||||
messages: results.map(message => ({
|
|
||||||
messageId: message.id,
|
|
||||||
senderName: message.sender_name,
|
|
||||||
receiverName: message.receiver_name,
|
|
||||||
content: message.content,
|
|
||||||
createdAt: message.created_at
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res.json({
|
|
||||||
code: 401,
|
|
||||||
message: "未找到联系人消息",
|
|
||||||
data: {
|
|
||||||
messages: []
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 写入聊天消息
|
|
||||||
router.post("/sendMessage", (req, res) => {
|
|
||||||
const message = req.body;
|
|
||||||
const sql = `
|
|
||||||
INSERT INTO messages
|
|
||||||
(sender_name, receiver_name, content)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
`;
|
|
||||||
|
|
||||||
db.query(sql, [
|
|
||||||
message.senderName,
|
|
||||||
message.receiverName,
|
|
||||||
message.content
|
|
||||||
], (error, result) => {
|
|
||||||
if (error) {
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
return res.status(500).json({
|
|
||||||
code: 500,
|
|
||||||
message: "发送消息失败",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
code: 200,
|
|
||||||
message: "发送消息成功",
|
|
||||||
data: null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
const WebSocket = require("ws");
|
||||||
|
|
||||||
|
const onlineUsers = new Map();
|
||||||
|
|
||||||
|
// 保存用户连接
|
||||||
|
function addConnection(username, ws) {
|
||||||
|
onlineUsers.set(username, ws);
|
||||||
|
|
||||||
|
console.log(`用户 ${username} 已连接`);
|
||||||
|
console.log("当前在线用户:", [...onlineUsers.keys()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户连接
|
||||||
|
function getConnection(username) {
|
||||||
|
return onlineUsers.get(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除用户连接
|
||||||
|
function removeConnection(username, ws) {
|
||||||
|
const currentWs = onlineUsers.get(username);
|
||||||
|
|
||||||
|
// 只有 Map 里面存的还是这个连接,才允许删除
|
||||||
|
if (currentWs === ws) {
|
||||||
|
onlineUsers.delete(username);
|
||||||
|
}
|
||||||
|
console.log(`用户 ${username} 已断开`);
|
||||||
|
console.log("当前在线用户:", [...onlineUsers.keys()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有在线用户
|
||||||
|
function getOnlineUsers() {
|
||||||
|
return Array.from(onlineUsers.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 广播所有在线用户
|
||||||
|
function broadcast(data) {
|
||||||
|
const message = JSON.stringify(data);
|
||||||
|
|
||||||
|
for (const [username, ws] of onlineUsers) {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(message, (error) => {
|
||||||
|
if (error) {
|
||||||
|
console.error(`向用户 ${username} 广播失败:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
addConnection,
|
||||||
|
getConnection,
|
||||||
|
removeConnection,
|
||||||
|
getOnlineUsers,
|
||||||
|
broadcast
|
||||||
|
};
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
const WebSocket = require("ws");
|
||||||
|
const url = require("url");
|
||||||
|
const db = require("../db/mysql");
|
||||||
|
|
||||||
|
const { verifyToken } = require("../utils/jwt");
|
||||||
|
|
||||||
|
const {
|
||||||
|
addConnection,
|
||||||
|
getConnection,
|
||||||
|
removeConnection,
|
||||||
|
getOnlineUsers,
|
||||||
|
broadcast
|
||||||
|
} = require("./connectionManager");
|
||||||
|
|
||||||
|
// 创建WebSocket服务
|
||||||
|
function initWebSocket(server) {
|
||||||
|
const wss = new WebSocket.Server({
|
||||||
|
server,
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on("connection", (ws, request) => {
|
||||||
|
console.log("有新的WebSocket连接");
|
||||||
|
|
||||||
|
// 获取URL中的token
|
||||||
|
const query = url.parse(request.url, true).query;
|
||||||
|
const token = query.token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.log("没有提供token");
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证JWT
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
user = verifyToken(token);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("JWT验证失败:", error.message);
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("WebSocket用户:", user);
|
||||||
|
|
||||||
|
const username = user.username;
|
||||||
|
ws.username = username;
|
||||||
|
// 保存连接
|
||||||
|
addConnection(username, ws);
|
||||||
|
|
||||||
|
// 通知所有在线用户当前在线用户列表
|
||||||
|
broadcast({
|
||||||
|
type: "userOnline",
|
||||||
|
userList: getOnlineUsers()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 接收客户端消息
|
||||||
|
ws.on("message", (message) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message.toString());
|
||||||
|
console.log("收到WebSocket消息:", data);
|
||||||
|
|
||||||
|
// websocket收到消息,必须通过解析data内容才知道谁是发送者谁是接收者
|
||||||
|
handleMessage(data, ws);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("WebSocket消息处理失败:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 连接关闭
|
||||||
|
ws.on("close", () => {
|
||||||
|
removeConnection(username, ws);
|
||||||
|
|
||||||
|
// 通知所有在线用户当前在线用户列表
|
||||||
|
broadcast({
|
||||||
|
type: "userOnline",
|
||||||
|
userList: getOnlineUsers()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebSocket错误
|
||||||
|
ws.on("error", (error) => {
|
||||||
|
console.error(`用户 ${username} WebSocket错误:`, error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("WebSocket服务已启动");
|
||||||
|
|
||||||
|
return wss;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理WebSocket消息
|
||||||
|
function handleMessage(data, ws) {
|
||||||
|
switch (data.type) {
|
||||||
|
case "sendMessage":
|
||||||
|
handleSendMessage(data, ws);
|
||||||
|
break;
|
||||||
|
case "ack":
|
||||||
|
handleAck(data, ws);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log("未知消息类型:", data.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理发送消息
|
||||||
|
function handleSendMessage(data, ws) {
|
||||||
|
const senderName = ws.username;
|
||||||
|
const messageId = data.messageId;
|
||||||
|
const receiverName = data.receiverName;
|
||||||
|
const content = data.content;
|
||||||
|
|
||||||
|
if (!messageId) {
|
||||||
|
console.log("消息缺少 messageId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!receiverName) {
|
||||||
|
console.log("消息缺少接收者");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
console.log("消息内容为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${senderName} → ${receiverName}:${content}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertSql = `
|
||||||
|
INSERT INTO messages
|
||||||
|
(message_id, sender_name, receiver_name, content)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
db.query(insertSql, [
|
||||||
|
messageId,
|
||||||
|
senderName,
|
||||||
|
receiverName,
|
||||||
|
content
|
||||||
|
], (error, result) => {
|
||||||
|
const senderWs = getConnection(senderName);
|
||||||
|
if (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
if (senderWs) {
|
||||||
|
senderWs.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "sendError",
|
||||||
|
messageId: messageId,
|
||||||
|
message: "消息保存失败"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入新消息之后,用messageID查询这条新消息的时间点(为了使用数据库插入时创建的时间点,不能用前端自己生成的)
|
||||||
|
const timeSql = `
|
||||||
|
SELECT created_at
|
||||||
|
FROM messages
|
||||||
|
WHERE message_id = ?
|
||||||
|
`;
|
||||||
|
db.query(timeSql, [messageId], (error, results) => {
|
||||||
|
if (error) {
|
||||||
|
console.error("查询消息失败:", error);
|
||||||
|
|
||||||
|
if (senderWs) {
|
||||||
|
senderWs.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "sendError",
|
||||||
|
messageId: messageId,
|
||||||
|
message: "获取消息失败"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
if (senderWs) {
|
||||||
|
senderWs.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "sendError",
|
||||||
|
messageId: messageId,
|
||||||
|
message: "获取消息失败"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = results[0].created_at;
|
||||||
|
|
||||||
|
// 数据库确认写入后,才把成功写入的消息推送给发送者
|
||||||
|
if (senderWs) {
|
||||||
|
senderWs.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "sendSuccess",
|
||||||
|
message: {
|
||||||
|
messageId,
|
||||||
|
senderName,
|
||||||
|
receiverName,
|
||||||
|
content,
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把写入的新消息推送给接收者,如果接收者不在线,就不用发送websocket消息给他了,但数据库里是有的
|
||||||
|
const receiverWs = getConnection(receiverName);
|
||||||
|
if (!receiverWs) {
|
||||||
|
console.log(`${receiverName} 当前不在线`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
receiverWs.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "newMessage",
|
||||||
|
message: {
|
||||||
|
messageId,
|
||||||
|
senderName,
|
||||||
|
receiverName,
|
||||||
|
content,
|
||||||
|
createdAt
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAck(data, ws) {
|
||||||
|
const messageId = data.messageId;
|
||||||
|
|
||||||
|
// 当前 WebSocket 对应的真实用户
|
||||||
|
const ackUser = ws.username;
|
||||||
|
|
||||||
|
if (!messageId) {
|
||||||
|
console.log("ACK 缺少 messageId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT sender_name, receiver_name
|
||||||
|
FROM messages
|
||||||
|
WHERE message_id = ?
|
||||||
|
`;
|
||||||
|
db.query(sql, [messageId], (error, results) => {
|
||||||
|
if (error) {
|
||||||
|
console.error("查询消息失败:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
console.log(`消息 ${messageId} 不存在`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = results[0];
|
||||||
|
// 当前用户必须是这条消息的接收者
|
||||||
|
if (message.receiver_name !== ackUser) {
|
||||||
|
console.log(
|
||||||
|
`${ackUser} 无权确认消息 ${messageId}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原消息发送者
|
||||||
|
const originalSender = message.sender_name;
|
||||||
|
console.log(
|
||||||
|
`${ackUser} 已收到来自 ${originalSender} 的消息 ${messageId},发送 ACK`
|
||||||
|
);
|
||||||
|
|
||||||
|
const senderWs = getConnection(originalSender);
|
||||||
|
if (!senderWs) {
|
||||||
|
console.log(
|
||||||
|
`${originalSender} 当前不在线`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
senderWs.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "ack",
|
||||||
|
messageId: messageId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`ACK ${messageId} 已发送给 ${originalSender}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = initWebSocket;
|
||||||
Reference in New Issue
Block a user