Compare commits

..
13 Commits
30 changed files with 6427 additions and 136 deletions
+208
View File
@@ -0,0 +1,208 @@
# Chat Demo Server 设计说明
## 1. 文档范围
本文依据当前仓库中的代码和 SQL 脚本,记录聊天服务端现有结构、模块职责、接口和消息协议、数据模型及设计取舍。文中“当前行为”指代码已经实现的行为;代码中存在但可能导致运行失败或安全风险的部分会在“已知问题与建议”中单独说明,不将计划性改进描述为已有能力。
## 2. 项目概述
本项目是一个基于 Node.js 的单进程聊天服务端,提供 HTTP REST 接口和 WebSocket 实时连接。Express 和 WebSocket 共用同一个 HTTP Server,监听 3000 端口;MySQL 保存用户、联系人和聊天消息。
当前实现的主要能力:
- 用户注册、用户名密码登录,并在登录成功后签发 JWT。
- 使用 JWT 中间件保护用户信息、联系人和聊天记录查询接口。
- 查询联系人、每个联系人的最近一条消息,以及指定联系人的分页消息。
- WebSocket 连接通过 JWT 验证身份,维护进程内在线用户连接表,并广播在线列表。
- WebSocket 消息处理代码包含消息发送、投递和 ACK 转发逻辑。
## 3. 技术栈与依赖
| 技术/依赖 | 用途 |
| --- | --- |
| Node.js | JavaScript 服务端运行环境 |
| Express 5 | HTTP 服务、JSON 请求体解析和路由分发 |
| `http` | 创建与 Express 共用的 HTTP Server |
| `ws` | WebSocket 服务端实现 |
| `mysql2` | MySQL 单连接及参数化 SQL 查询 |
| `jsonwebtoken` | JWT 签发和验证 |
| `dotenv` | 将 `.env` 配置加载到 `process.env` |
| `nodemon` | 开发时监视文件并重启服务 |
项目使用 CommonJS 模块格式。`npm start` 执行 `node index.js`,`npm run dev` 执行 `nodemon index.js`。当前 `npm test` 是占位脚本,没有自动化测试。
## 4. 代码结构
```text
index.js 服务启动入口、HTTP 与 WebSocket 组装
db/
mysql.js MySQL 连接创建与导出
sql.txt users、contacts、messages 的建表及样例数据脚本
middleware/
authMiddleware.js HTTP 请求 JWT 校验
routes/
login.js 注册和登录接口
user.js 用户信息与联系人接口
message.js 最近聊天、历史消息、REST 消息写入接口
utils/
jwt.js JWT 签发和验证封装
websocket/
index.js WebSocket 握手、协议分发和消息处理
connectionManager.js 进程内在线连接管理及广播
```
### 模块职责与依赖
- `index.js`:加载环境变量,创建 Express 应用和 HTTP Server,启用 JSON 请求解析,注册 `/api` 路由并将同一个 Server 交给 WebSocket 初始化函数,最后监听 3000 端口。
- `db/mysql.js`:创建一个 `mysql2` 单连接,并作为共享模块导出,路由和 WebSocket 处理器直接调用 `db.query`。
- `routes/login.js`:处理公开的 `/login`、`/register`。登录成功后通过 JWT 工具生成 token。
- `routes/user.js`:依赖 `authMiddleware`,从 `req.user.username` 识别当前用户并提供用户信息与联系人查询。
- `routes/message.js`:历史记录及最近聊天查询依赖认证中间件;当前 REST `/sendMessage` 未挂载认证中间件。
- `middleware/authMiddleware.js`:从 `Authorization` 请求头拆出 token,调用 JWT 验证,将 payload 放入 `req.user` 后继续处理请求。
- `utils/jwt.js`:使用 `JWT_SECRET` 签发和验证 token,签发有效期为 10 分钟。
- `websocket/index.js`:从连接 URL 查询参数读取 token 验证客户端身份;连接建立或关闭时广播在线用户列表;按消息 `type` 分发 WebSocket 消息。
- `websocket/connectionManager.js`:使用模块级 `Map` 以用户名为键保存 WebSocket 连接。该状态仅存在于当前 Node 进程内。
- `db/sql.txt`:描述用户、联系人、消息表,以及本地演示用数据。
## 5. 总体架构与请求路径
```mermaid
flowchart LR
Client[客户端]
Entry[index.js / HTTP Server]
Express[Express 路由]
Auth[JWT 中间件与工具]
WS[WebSocket 服务]
Manager[在线连接 Map]
DB[(MySQL)]
Client -->|HTTP /api| Entry
Entry --> Express
Express --> Auth
Express --> DB
Client <-->|WebSocket| Entry
Entry --> WS
WS --> Auth
WS --> Manager
WS --> DB
```
HTTP 请求由 Express 根据 `/api` 前缀分发。需要认证的路由先运行 JWT 中间件,随后执行业务 SQL。WebSocket 则在握手后从 URL 查询参数读取 token,再在连接生命周期内以已验证的用户名处理消息。HTTP 与 WebSocket 最终都使用同一 MySQL 模块。
## 6. 数据模型
数据库脚本目标库为 `chat_mysql`,定义以下三张表:
### `users`
| 字段 | 类型 | 约束/用途 |
| --- | --- | --- |
| `username` | `VARCHAR(50)` | 主键,用户唯一标识 |
| `password` | `VARCHAR(255)` | 非空;当前代码保存并直接比较明文密码 |
### `contacts`
| 字段 | 类型 | 约束/用途 |
| --- | --- | --- |
| `user_name` | `VARCHAR(50)` | 联系人所属用户,外键引用 `users.username` |
| `contact_name` | `VARCHAR(50)` | 联系人用户名,外键引用 `users.username` |
`(user_name, contact_name)` 为联合主键。同一用户不能重复添加同一联系人。脚本中的联系人关系按有向关系保存,双向关系需要分别插入两行。
### `messages`
| 字段 | 类型 | 约束/用途 |
| --- | --- | --- |
| `id` | `INT` | 自增主键 |
| `sender_name` | `VARCHAR(50)` | 非空,外键引用发送者 |
| `receiver_name` | `VARCHAR(50)` | 非空,外键引用接收者 |
| `content` | `VARCHAR(1000)` | 非空,消息正文 |
| `created_at` | `DATETIME` | 默认使用数据库当前时间 |
消息以一条记录表示一次单向发送。双方聊天记录通过发送者和接收者字段组合查询;表中没有当前 WebSocket 处理器所写入的 `message_id` 字段。
## 7. HTTP 接口设计
所有路由统一挂载在 `/api` 下,JSON 请求体由 Express 解析。成功/失败响应大多采用 `{ code, message, data }` 结构,但不同接口的 HTTP 状态码与业务 `code` 使用并不完全一致。
| 方法 | 路径 | 认证 | 输入/用途 |
| --- | --- | --- | --- |
| `POST` | `/api/register` | 否 | body:`username`、`password`;插入用户 |
| `POST` | `/api/login` | 否 | body:`username`、`password`;验证凭据并返回 token、用户名 |
| `GET` | `/api/userInfo` | 是 | 从 token 返回当前用户名 |
| `GET` | `/api/contactsInfo` | 是 | 查询当前用户联系人,返回 `contacts` 数组 |
| `GET` | `/api/recentChats` | 是 | 查询当前用户每个聊天对象的最新消息 |
| `POST` | `/api/contactMessagesInfo` | 是 | body:`contactName`、`limit`、`offset`;分页查询双方消息 |
| `POST` | `/api/sendMessage` | **否** | body:`senderName`、`receiverName`、`content`;将消息插入数据库 |
### 认证约定
HTTP 客户端应在受保护接口的请求头中传入 `Authorization: Bearer <token>`。登录接口响应的 token 字符串自身已带 `Bearer ` 前缀。当前中间件通过空格拆分请求头并取第二段作为 token;缺少请求头或验证失败时返回 401。
JWT payload 当前包含 `username`,过期时间为 10 分钟。WebSocket 客户端把 token 放在连接 URL 查询参数 `token` 中。由于登录响应包含 `Bearer ` 前缀,而 WebSocket 端会直接将查询参数交给 `verifyToken`,客户端需要传纯 JWT 部分,不能把 `Bearer ` 前缀一并作为 token 值。
## 8. WebSocket 协议与消息流程
WebSocket 挂载在 HTTP Server 上,客户端连接地址的形式为 `ws://<host>:3000/?token=<JWT>`。连接验证通过后,服务端按用户名登记连接,并广播在线列表。
### 服务端消息类型
| `type` | 方向 | 当前代码行为 |
| --- | --- | --- |
| `userOnline` | 服务端 -> 所有在线客户端 | 连接建立或关闭后,携带 `userList` 在线用户名数组 |
| `newMessage` | 服务端 -> 接收方 | 携带 `message` 对象,包括消息 ID、发送方、接收方和正文 |
| `sendSuccess` | 服务端 -> 发送方 | 携带客户端提供的 `messageId`,表示发送成功 |
| `sendError` | 服务端 -> 发送方 | 数据库写入回调报错时尝试发送,携带 `messageId` 和错误说明 |
| `ack` | 服务端 -> 原发送方 | 将接收方确认的消息 ID 转发给发送方 |
### 客户端消息类型
- `sendMessage`:处理器读取 `messageId`、`receiverName`、`content`;发送者身份来自已验证 WebSocket 连接,不从消息体读取。
- `ack`:处理器预期使用 `messageId` 和 `senderName` 将 ACK 转发给原发送者。
### 预期发送流程
1. 客户端建立携带 JWT 的 WebSocket 连接。
2. 服务端校验 JWT,登记用户名与连接,并广播在线用户列表。
3. 客户端发送 `sendMessage`,服务端使用连接身份确定发送者。
4. 服务端尝试把消息持久化到 MySQL;接收方在线时向其推送 `newMessage`。
5. 接收方返回 `ack` 后,服务端尝试通知原发送方。
步骤 4 当前实现没有等待数据库回调成功再确认和推送;此外建表脚本与插入语句字段不匹配,因此此流程目前不能视为可靠送达流程。具体差异见第 10 节。
## 9. 关键设计思路
- **按职责拆分模块**:入口负责组装,路由负责 HTTP 接口,middleware 负责认证,utils 封装 JWT,websocket 目录处理长连接和连接表,db 统一导出数据库连接。
- **用户名作为用户标识**:用户表以用户名为主键,联系人关系、消息发送接收双方及在线连接表均使用用户名关联。
- **JWT 无服务端会话存储**:服务端通过签名和过期时间验证 token,HTTP 请求和 WebSocket 握手各自执行验证。
- **聊天列表与历史消息分开查询**:`recentChats` 使用窗口函数按聊天对象分组并选出最新消息;历史消息接口按 limit/offset 分页,避免单次返回全部记录。
- **在线状态在内存维护**:当前使用 Map 直接由用户名定位 WebSocket 连接,便于单进程内向在线用户推送消息,但没有跨进程共享能力。
- **SQL 参数化**:业务查询使用 `?` 占位符与参数数组传值,避免把用户输入直接拼入 SQL 文本。
## 10. 已知问题与后续建议
以下内容是基于当前代码与脚本的直接核对结果,应在扩展功能或对外部署前处理:
1. **数据库凭据写在源码中**:`db/mysql.js` 将 MySQL 账号、密码和数据库名直接传给连接创建函数。建议改为读取环境变量,轮换已经放入源码的凭据,并避免将真实密钥写入文档或版本库。
2. **密码以明文存储和比对**:注册直接写入密码,登录 SQL 按用户名与明文密码匹配。应使用成熟密码哈希方案存储和校验,并考虑统一认证失败响应。
3. **REST 消息写入缺少认证**:`/api/sendMessage` 未使用 `authMiddleware`,客户端可自行指定发送者。应要求认证并从 `req.user` 派生发送者身份,同时校验接收方及联系人关系。(已解决)
4. **WebSocket 数据库写入字段不匹配**:`websocket/index.js` 插入 `message_id`,但 `db/sql.txt` 的 `messages` 表没有该列。需统一消息 ID 设计,例如新增唯一字段及迁移,或使用现有自增 `id` 并调整协议和查询。(已解决)
5. **发送确认早于持久化结果**:`sendSuccess` 和接收方推送发生在异步 `db.query` 回调之前。应将成功确认和实时推送放到成功回调中;失败时只返回错误,不推送为已发送消息。(已解决)
6. **连接管理器广播引用未定义的 `WebSocket`**:`connectionManager.js` 的 `broadcast` 检查 `WebSocket.OPEN`,但文件中没有导入 `ws`。当广播函数被调用时可能抛出 `ReferenceError`。应在此模块引入库常量,或由连接对象状态采用明确且可用的检查方式。(已解决)
7. **ACK 参数调用不一致**:消息分发通过 `handleAck(data)` 调用,而函数签名是 `handleAck(receiverName, data)`;函数内部随后读取第二个参数,可能因 `data` 未定义而失败。应统一函数参数并以当前连接用户名作为 ACK 接收者身份。(已解决)
8. **重复登录连接的关闭竞态**:同一用户名的新连接会覆盖 Map 中旧连接;旧连接关闭时无条件按用户名删除,可能把新连接也从 Map 移除。移除连接时应确认 Map 中仍是即将关闭的那个 WebSocket。(已解决)
9. **分页和输入校验不足**:`contactMessagesInfo` 未验证 `contactName`、`limit`、`offset` 的类型与范围,也未约束最大页大小。建议验证请求参数并设置默认值和上限。
10. **在线状态及可靠性受单进程限制**:Map 不支持多实例共享、服务重启恢复、离线消息投递或持久化 ACK 状态。若部署多实例,需要共享在线状态/消息协调机制;若要求可靠消息,应定义消息状态、幂等键、重试及离线投递策略。
11. **配置与错误响应尚未统一**:数据库连接配置没有从 `.env` 读取;HTTP 接口混用 HTTP 状态码和响应体 `code`,空结果有时返回业务码 401。建议集中配置并统一 API 错误语义及日志策略。
12. **SQL 初始化脚本需要整理验证**:脚本包含清空用户表的语句,执行前会删除该表全部用户数据。应将演示数据与建表迁移分离,并在测试数据库验证脚本。
13. **缺少自动化测试**:当前测试脚本是占位命令。建议优先为认证边界、参数校验、消息持久化失败/成功和 WebSocket 连接生命周期补充测试。
## 11. 本地运行与部署边界
1. 安装 Node.js 及 MySQL,并在 MySQL 中准备 `chat_mysql` 数据库。
2. 按需执行经过校验的建表脚本;注意当前 `sql.txt` 含删除数据语句,不应直接用于已有数据环境。
3. 设置 `JWT_SECRET`。当前 MySQL 参数仍需在 `db/mysql.js` 中配置;建议先将连接参数迁移到环境变量,再部署。
4. 执行 `npm install`,开发环境使用 `npm run dev`,普通启动使用 `npm start`。
5. 服务监听 `http://localhost:3000`,根路径 `/` 返回运行提示;WebSocket 使用相同主机和端口。
当前代码适用于本地演示和单进程验证,不具备生产环境所需的凭据管理、密码安全、输入校验、多实例在线状态和消息可靠性保障。
+11 -46
View File
@@ -11,7 +11,8 @@ VALUES
('query', '123'),
('tom', '123'),
('jack', '123'),
('lucy', '123');
('lucy', '123'),
('lily', '123');
select * from chat_mysql.users;
@@ -36,11 +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,
@@ -52,48 +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;
#删除某个人的发送聊天数据
SELECT *
FROM chat_mysql.messages
WHERE sender_name = 'test';
DELETE FROM chat_mysql.messages WHERE sender_name = 'test';
+18 -1
View File
@@ -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
View File
@@ -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
}
}
}
}
}
Generated Vendored
+20
View File
@@ -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.
Generated Vendored
+548
View File
@@ -0,0 +1,548 @@
# ws: a Node.js WebSocket library
[![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws)
[![CI](https://img.shields.io/github/actions/workflow/status/websockets/ws/ci.yml?branch=master&label=CI&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster)
[![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg?logo=coveralls)](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
Generated Vendored
+8
View File
@@ -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'
);
};
Generated Vendored
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+74
View File
@@ -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
View File
@@ -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;
+23 -1
View File
@@ -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
View File
@@ -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"
+1
View File
@@ -70,6 +70,7 @@ router.post("/register", (req, res) => {
return res.status(500).json({
code: 500,
message: "注册失败",
data: null
});
}
+167
View File
@@ -0,0 +1,167 @@
const express = require("express");
const router = express.Router();
const db = require("../db/mysql");
const authMiddleware = require("../middleware/authMiddleware");
// 获取最近联系人的最后一条聊天消息
router.get(
"/recentChats",
authMiddleware,
(req, res) => {
const currentUserName = req.user.username;
const sql = `
SELECT
contact_name,
content,
created_at
FROM (
SELECT
CASE
WHEN sender_name = ? THEN receiver_name
ELSE sender_name
END AS contact_name,
content,
created_at,
ROW_NUMBER() OVER (
PARTITION BY
CASE
WHEN sender_name = ? THEN receiver_name
ELSE sender_name
END
ORDER BY created_at DESC
) AS rn
FROM chat_mysql.messages
WHERE sender_name = ?
OR receiver_name = ?
) t
WHERE rn = 1
ORDER BY created_at DESC;
`;
db.query(sql, [currentUserName, currentUserName, currentUserName, currentUserName], (error, results) => {
if (error) {
console.error("数据库查询失败:", error);
return res.status(500).json({
code: 500,
message: "查询失败",
data: null
});
}
if (results.length > 0) {
res.json({
code: 200,
message: "查询成功",
data: {
recentChats: results.map(message => ({
contactName: message.contact_name,
content: message.content,
createdAt: message.created_at
}))
},
});
} else {
res.json({
code: 401,
message: "未找到联系人消息",
data: {
recentChats: []
}
});
}
});
}
);
// 获取某个联系人的最近n条聊天消息,分页获取
router.post(
"/contactMessagesInfo",
authMiddleware,
(req, res) => {
const currentUserName = req.user.username;
const { contactName, limit, offset } = req.body;
const sql = `
SELECT
id,
sender_name,
receiver_name,
content,
created_at
FROM chat_mysql.messages
WHERE (sender_name = ? OR receiver_name = ?) AND (sender_name = ? OR receiver_name = ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?;
`;
db.query(sql, [currentUserName, currentUserName, contactName, contactName, limit, offset], (error, results) => {
if (error) {
console.error("数据库查询失败:", error);
return res.status(500).json({
code: 500,
message: "查询失败",
data: null
});
}
if (results.length > 0) {
res.json({
code: 200,
message: "查询成功",
data: {
messages: results.map(message => ({
messageId: message.id,
senderName: message.sender_name,
receiverName: message.receiver_name,
content: message.content,
createdAt: message.created_at
}))
},
});
} else {
res.json({
code: 401,
message: "未找到联系人消息",
data: {
messages: []
}
});
}
});
}
);
// 写入聊天消息
router.post(
"/sendMessage",
authMiddleware,
(req, res) => {
const message = req.body;
const sql = `
INSERT INTO messages
(sender_name, receiver_name, content)
VALUES (?, ?, ?)
`;
db.query(sql, [
message.senderName,
message.receiverName,
message.content
], (error, result) => {
if (error) {
console.error(error);
return res.status(500).json({
code: 500,
message: "发送消息失败",
data: null
});
}
res.json({
code: 200,
message: "发送消息成功",
data: null
});
});
});
module.exports = router;
+4 -87
View File
@@ -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,90 +68,4 @@ router.get(
}
);
// 获取所有聊天消息
router.get(
"/messagesInfo",
authMiddleware,
(req, res) => {
const currentUserName = req.user.username;
const sql = `
SELECT
id,
sender_name,
receiver_name,
content,
created_at
FROM chat_mysql.messages
WHERE sender_name = ? OR receiver_name = ?
ORDER BY created_at ASC;
`;
db.query(sql, [currentUserName, currentUserName], (error, results) => {
if (error) {
console.error("数据库查询失败:", error);
return res.status(500).json({
code: 500,
message: "查询失败",
});
}
if (results.length > 0) {
res.json({
code: 200,
message: "查询成功",
data: {
messages: results.map(message => ({
messageId: message.id,
senderName: message.sender_name,
receiverName: message.receiver_name,
content: message.content,
createdAt: message.created_at
}))
},
});
} else {
res.json({
code: 401,
message: "未找到联系人消息",
data: {
messages: []
}
});
}
});
}
);
// 写入聊天消息
router.post("/sendMessage", (req, res) => {
const message = req.body;
const sql = `
INSERT INTO messages
(sender_name, receiver_name, content)
VALUES (?, ?, ?)
`;
db.query(sql, [
message.senderName,
message.receiverName,
message.content
], (error, result) => {
if (error) {
console.error(error);
return res.status(500).json({
code: 500,
message: "发送消息失败",
});
}
res.json({
code: 200,
message: "发送消息成功",
data: null
});
});
});
module.exports = router;
+56
View File
@@ -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
};
+299
View File
@@ -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;