Compare commits
11
Commits
1f1164ab69
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f5a8698e3 | ||
|
|
3dba76cd4a | ||
|
|
fa7e908a08 | ||
|
|
9dc511f08b | ||
|
|
5e57920039 | ||
|
|
3eb4bfcd5d | ||
|
|
4c3b99edac | ||
|
|
74ed1535ed | ||
|
|
aedd5d1f3f | ||
|
|
d02a2c7d5a | ||
|
|
991993e0c2 |
@@ -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 使用相同主机和端口。
|
||||
|
||||
当前代码适用于本地演示和单进程验证,不具备生产环境所需的凭据管理、密码安全、输入校验、多实例在线状态和消息可靠性保障。
|
||||
+8
-101
@@ -11,7 +11,7 @@ VALUES
|
||||
('query', '123'),
|
||||
('tom', '123'),
|
||||
('jack', '123'),
|
||||
('lucy', '123'),
|
||||
('lucy', '123'),
|
||||
('lily', '123');
|
||||
select * from chat_mysql.users;
|
||||
|
||||
@@ -37,13 +37,14 @@ VALUES
|
||||
('query', 'admin'),
|
||||
('query', 'tom'),
|
||||
('tom', 'admin'),
|
||||
('tom', 'query'),
|
||||
('tom', 'query'),
|
||||
('admin', 'lily'),
|
||||
('lily', 'admin');
|
||||
select * from chat_mysql.contacts;
|
||||
|
||||
CREATE TABLE chat_mysql.messages (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
message_id VARCHAR(64) NOT NULL,
|
||||
sender_name VARCHAR(50) NOT NULL,
|
||||
receiver_name VARCHAR(50) NOT NULL,
|
||||
content VARCHAR(1000) NOT NULL,
|
||||
@@ -55,103 +56,9 @@ CREATE TABLE chat_mysql.messages (
|
||||
FOREIGN KEY (receiver_name)
|
||||
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'),
|
||||
('tom', 'admin', '有啊,怎么了?', '2026-09-20 11:01:00'),
|
||||
('admin', 'tom', '想和你讨论一下项目。', '2026-09-20 11:02:00'),
|
||||
('tom', 'admin', '可以,什么时间方便?', '2026-09-20 11:03:00'),
|
||||
('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;
|
||||
|
||||
INSERT INTO chat_mysql.messages
|
||||
(sender_name, receiver_name, content, created_at)
|
||||
VALUES
|
||||
-- admin ↔ lily
|
||||
('admin', 'lily', '你好,最近怎么样?', '2026-09-20 10:00:00'),
|
||||
('lily', 'admin', '挺好的,你呢?', '2026-09-20 10:01:00'),
|
||||
('admin', 'lily', '我也不错,最近在做聊天项目。', '2026-09-20 10:02:00'),
|
||||
('lily', 'admin', '听起来不错,是用 Vue 做的吗?', '2026-09-20 10:03:00'),
|
||||
('admin', 'lily', '对,前端使用 Vue2,后端使用 Node.js。', '2026-09-20 10:04:00'),
|
||||
('lily', 'admin', '那数据库使用 MySQL?', '2026-09-20 10:05:00'),
|
||||
('admin', 'lily', '对,目前正在完善聊天功能。', '2026-09-20 10:06:00'),
|
||||
('lily', 'admin', '聊天记录是怎么保存的?', '2026-09-20 10:07:00'),
|
||||
('admin', 'lily', '目前是保存在 messages 表里面。', '2026-09-20 10:08:00'),
|
||||
('lily', 'admin', '一个消息对应一条数据吗?', '2026-09-20 10:09:00'),
|
||||
('admin', 'lily', '对,每条消息都有自己的 id。', '2026-09-20 10:10:00'),
|
||||
('lily', 'admin', '那发送人和接收人怎么区分?', '2026-09-20 10:11:00'),
|
||||
('admin', 'lily', '通过 sender_name 和 receiver_name 两个字段区分。', '2026-09-20 10:12:00'),
|
||||
('lily', 'admin', '这样查询两个人的聊天记录应该比较方便。', '2026-09-20 10:13:00'),
|
||||
('admin', 'lily', '对,只需要判断双方的用户名即可。', '2026-09-20 10:14:00'),
|
||||
('lily', 'admin', '比如查询 admin 和 lily 之间的消息?', '2026-09-20 10:15:00'),
|
||||
('admin', 'lily', '没错,可以使用 OR 和 AND 组合条件。', '2026-09-20 10:16:00'),
|
||||
('lily', 'admin', '那如果消息很多怎么办?', '2026-09-20 10:17:00'),
|
||||
('admin', 'lily', '不能一次把所有消息都返回给前端。', '2026-09-20 10:18:00'),
|
||||
('lily', 'admin', '应该使用分页查询吧?', '2026-09-20 10:19:00'),
|
||||
('admin', 'lily', '对,比如一次只查询 10 条或者 20 条。', '2026-09-20 10:20:00'),
|
||||
('lily', 'admin', '这样可以减少接口返回的数据量。', '2026-09-20 10:21:00'),
|
||||
('admin', 'lily', '也可以提高页面加载速度。', '2026-09-20 10:22:00'),
|
||||
('lily', 'admin', '最近聊天列表也是单独查询吗?', '2026-09-20 10:23:00'),
|
||||
('admin', 'lily', '对,最近聊天只需要获取每个联系人的最新消息。', '2026-09-20 10:24:00'),
|
||||
('lily', 'admin', '这样左边就不用加载完整聊天记录了。', '2026-09-20 10:25:00'),
|
||||
('admin', 'lily', '对,点击联系人之后再加载具体的聊天记录。', '2026-09-20 10:26:00'),
|
||||
('lily', 'admin', '这个设计比较合理。', '2026-09-20 10:27:00'),
|
||||
('admin', 'lily', '我现在就是准备按照这个思路修改。', '2026-09-20 10:28:00'),
|
||||
('lily', 'admin', '那 Vue 里面是不是也不用保存所有消息?', '2026-09-20 10:29:00'),
|
||||
('admin', 'lily', '是的,只需要保存当前联系人的消息数组。', '2026-09-20 10:30:00'),
|
||||
('lily', 'admin', '分页的时候再把历史消息添加进去?', '2026-09-20 10:31:00'),
|
||||
('admin', 'lily', '对,可以把下一页的数据添加到数组前面。', '2026-09-20 10:32:00'),
|
||||
('lily', 'admin', '那新发送的消息就添加到数组最后面?', '2026-09-20 10:33:00'),
|
||||
('admin', 'lily', '没错,因为新消息的时间是最新的。', '2026-09-20 10:34:00'),
|
||||
('lily', 'admin', '左边的最近聊天还需要移动到最上面。', '2026-09-20 10:35:00'),
|
||||
('admin', 'lily', '对,可以使用 unshift 把它移动到第一位。', '2026-09-20 10:36:00'),
|
||||
('lily', 'admin', '同时更新左边显示的最后一条消息。', '2026-09-20 10:37:00'),
|
||||
('admin', 'lily', '这样聊天列表就会实时更新了。', '2026-09-20 10:38:00'),
|
||||
('lily', 'admin', '如果切换联系人,消息数组怎么办?', '2026-09-20 10:39:00'),
|
||||
('admin', 'lily', '可以按照联系人 id 分别保存。', '2026-09-20 10:40:00'),
|
||||
('lily', 'admin', '比如 contactMessages[18] 保存 lily 的消息?', '2026-09-20 10:41:00'),
|
||||
('admin', 'lily', '对,contactMessages[20] 就可以保存另一个人的消息。', '2026-09-20 10:42:00'),
|
||||
('lily', 'admin', 'Vue2 动态增加这个属性需要注意响应式问题吧?', '2026-09-20 10:43:00'),
|
||||
('admin', 'lily', '对,可以使用 this.$set 来创建新的消息数组。', '2026-09-20 10:44:00'),
|
||||
('lily', 'admin', '这样切换联系人就比较方便了。', '2026-09-20 10:45:00'),
|
||||
('admin', 'lily', '对,聊天页面的数据结构基本就确定了。', '2026-09-20 10:46:00'),
|
||||
('lily', 'admin', '那接下来就可以测试分页加载了。', '2026-09-20 10:47:00'),
|
||||
('admin', 'lily', '嗯,先测试一次查询 10 条消息。', '2026-09-20 10:48:00'),
|
||||
('lily', 'admin', '然后再查询下一页历史消息。', '2026-09-20 10:49:00');
|
||||
#删除某个人的发送聊天数据
|
||||
SELECT *
|
||||
FROM chat_mysql.messages
|
||||
WHERE sender_name = 'test';
|
||||
DELETE FROM chat_mysql.messages WHERE sender_name = 'test';
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
console.log("当前执行文件:", __filename);
|
||||
// .env 中的配置写入 Node.js 的全局环境变量对象,下面的文件都可以直接使用
|
||||
require('dotenv').config();
|
||||
|
||||
// 搭建http服务器
|
||||
const express = require("express");
|
||||
const http = require("http");
|
||||
|
||||
const app = express();
|
||||
|
||||
// 创建HTTP服务器
|
||||
const server = http.createServer(app);
|
||||
|
||||
// 服务器解析 JSON 请求体
|
||||
app.use(express.json());
|
||||
|
||||
// 防止控制台打印404
|
||||
app.get("/", (req, res) => {
|
||||
res.send("chat-demo-server运行正常");
|
||||
});
|
||||
|
||||
// 接入路由
|
||||
const loginRouter = require("./routes/login");
|
||||
const userRouter = require("./routes/user");
|
||||
const messageRouter = require("./routes/message");
|
||||
|
||||
// 所有接口,都统一加上 /api 前缀
|
||||
app.use("/api", loginRouter);
|
||||
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("WebSocket服务器启动成功:ws://localhost:3000");
|
||||
});
|
||||
|
||||
// index.js 就是 Node 后端的“总入口”:加载配置 → 创建 Express → 解析请求 → 加载路由 → 注册接口 → 启动 3000 端口服务器
|
||||
+21
@@ -1448,6 +1448,27 @@
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"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",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mysql2": "^3.24.2"
|
||||
"mysql2": "^3.24.2",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.14"
|
||||
@@ -1477,6 +1478,27 @@
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"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",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mysql2": "^3.24.2"
|
||||
"mysql2": "^3.24.2",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.14"
|
||||
|
||||
@@ -70,6 +70,7 @@ router.post("/register", (req, res) => {
|
||||
return res.status(500).json({
|
||||
code: 500,
|
||||
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
-157
@@ -11,6 +11,7 @@ router.get(
|
||||
(req, res) => {
|
||||
res.json({
|
||||
code: 200,
|
||||
message: "查询成功",
|
||||
data: {
|
||||
username: req.user.username
|
||||
}
|
||||
@@ -30,7 +31,8 @@ router.get(
|
||||
FROM chat_mysql.contacts c
|
||||
JOIN chat_mysql.users u
|
||||
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) => {
|
||||
if (error) {
|
||||
@@ -39,6 +41,7 @@ router.get(
|
||||
return res.status(500).json({
|
||||
code: 500,
|
||||
message: "查询失败",
|
||||
data: null
|
||||
});
|
||||
}
|
||||
if (results.length > 0) {
|
||||
@@ -65,160 +68,4 @@ router.get(
|
||||
}
|
||||
);
|
||||
|
||||
// 获取最近联系人的最后一条聊天消息
|
||||
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: "查询失败",
|
||||
});
|
||||
}
|
||||
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: "查询失败",
|
||||
});
|
||||
}
|
||||
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;
|
||||
@@ -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