Engine.IO 协议 v4 深度解析:握手、心跳、传输升级与包编码规范
本文基于 Socket.IO 仓库中的官方协议文档 Engine.IO Protocol v4-current 撰写,系统讲解 Engine.IO 协议 v4.1 的三大传输层(HTTP long-polling、WebSocket、WebTransport)、握手/心跳/升级流程以及按传输方式区分的包编码规则。读完本文,你将能够理解一次 Engine.IO 连接从建立到传输升级的完整报文交互,并能对照仓库中的参考实现(engine.io 服务端、engine.io-parser)自行实现或校验任意语言的服务端/客户端。
协议定位与参考实现
Engine.IO 协议的目标是在客户端与服务器之间提供全双工(full-duplex)、低开销的通信通道。它基于 WebSocket 协议,并在 WebSocket 连接无法建立时回退到 HTTP long-polling。协议本身是语言无关的规范文本,参考实现用 TypeScript 编写,即本仓库中的:
- 服务端:engine.io 包,入口导出
Server、transports、listen、attach与parser; - 客户端:engine.io-client 包;
- 解析器:engine.io-parser 包,协议版本常量
protocol = 4就定义在 index.ts。
上层的 Socket.IO 协议构建在 Engine.IO 提供的通信通道之上,增加了命名空间、事件等特性(参见 Socket.IO 协议 v5 文档)。理解 Engine.IO 是理解整个 Socket.IO 体系通信链路的第一步。
传输层(Transports)
Engine.IO 客户端与服务端之间的连接可以通过三种传输建立:HTTP long-polling、WebSocket、WebTransport。在服务端源码中,这三者对应 transports 目录 下的同名实现,且 Server 默认只启用 ["polling", "websocket"],WebTransport 需要手动开启:
new Server({
transports: ["polling", "websocket", "webtransport"],
});
HTTP long-polling
HTTP long-polling(也简称为 "polling")由一连串 HTTP 请求构成:
- 长耗时的
GET请求:用于接收服务器数据; - 短耗时的
POST请求:用于向服务器发送数据。
请求路径
HTTP 请求的默认路径为 /engine.io/。由协议之上构建的库可以改写该路径(例如 Socket.IO 使用 /socket.io/)。源码印证:BaseServer._computePath 中路径默认值为 /engine.io,且除非 addTrailingSlash: false,会追加尾部斜杠——这就是文档要求路径以 / 结尾的原因。
查询参数
| 名称 | 值 | 说明 |
|---|---|---|
EIO |
4 |
必填,协议版本号。 |
transport |
polling |
必填,传输层名称。 |
sid |
<sid> |
会话建立后必填,会话标识符。 |
任一必填查询参数缺失时,服务器必须(MUST)返回 HTTP 400。版本号的解析在 handshake() 中完成:req._query.EIO === "4" ? 4 : 3,若协议为 3 且服务端未配置 allowEIO3,则直接以 UNSUPPORTED_PROTOCOL_VERSION 错误断开,体现了服务端对旧版客户端的兼容开关。
请求合法性校验集中在 BaseServer.verify():transport 不在允许列表中返回 UNKNOWN_TRANSPORT;sid 存在但查不到对应会话返回 UNKNOWN_SID;带 sid 但传输与当前会话传输不一致且不是升级请求,返回 TRANSPORT_MISMATCH;无 sid 的握手请求只允许 GET 方法(BAD_HANDSHAKE_METHOD)。
请求头
发送二进制数据时,发送方(客户端或服务端)必须携带 Content-Type: application/octet-stream 头;没有显式 Content-Type 时,接收方应当(SHOULD)按纯文本处理。值得注意的一个实现细节:v4 协议下 long-polling 的 POST 请求不允许使用 octet-stream,因为二进制载荷统一走 base64,见 onDataRequest() 中 isBinary && this.protocol === 4 时直接返回 400。
发送与接收数据
发送数据:客户端通过 POST 请求把编码后的包放入请求体:
CLIENT SERVER
│ │
│ POST /engine.io/?EIO=4&transport=polling&sid=... │
│ ───────────────────────────────────────────────────► │
│ ◄──────────────────────────────────────────────────┘ │
│ HTTP 200 │
│ │
sid对应的会话未知时,服务器必须返回 HTTP 400;- 成功时服务器必须返回 HTTP 200,且响应体为字符串
ok——这与 polling.ts 中res.end("ok")的实现完全一致(响应头还特意用了text/html以避免部分用户代理弹出下载对话框); - 为保证包顺序,客户端同一时刻只能有一个活跃的
POST请求。若出现重叠,服务器必须返回 HTTP 400 并关闭会话。源码中 onDataRequest() 检测到this.dataReq已存在时返回 400 并触发data request overlap from client错误; - 另外,POST 体超过
maxHttpBufferSize(即握手中的maxPayload)时,服务器会返回 HTTP 413 并丢弃该请求,见 polling.ts。
接收数据:客户端发起 GET 请求:
CLIENT SERVER
│ GET /engine.io/?EIO=4&transport=polling&sid=... │
│ ──────────────────────────────────────────────────► │
│ . │
│ . │
│ . │
│ ◄─────────────────────────────────────────────────┘ │
│ HTTP 200 │
sid未知时服务器必须返回 HTTP 400;- 若该会话没有缓冲的包,服务器可以(MAY)不立即响应;一旦有包要发,就按包编码规则编码后放入 HTTP 响应体。源码中 onPollRequest() 会挂起 GET 请求并置
writable = true,把响应“挂起”等待数据; - 为保证包顺序,客户端同一时刻只能有一个活跃的
GET请求,出现重叠时服务器必须返回 HTTP 400 并关闭会话——对应源码中request overlap分支。
WebSocket
WebSocket 传输建立一条 WebSocket 连接(参考 RFC 6455),提供双向、低延迟的通信通道。使用的查询参数如下:
| 名称 | 值 | 说明 |
|---|---|---|
EIO |
4 |
必填,协议版本号。 |
transport |
websocket |
必填,传输层名称。 |
sid |
<sid> |
可选,取决于是否为从 HTTP long-polling 升级而来。 |
任一必填参数缺失时,服务器必须关闭 WebSocket 连接。注意 verify() 中一条约束:没有 sid 的 websocket 握手会被拒绝(TRANSPORT_HANDSHAKE_ERROR)——即默认情况下 WebSocket 只作为升级目标出现,独立的 websocket-only 会话需另行支持。每个包(读或写)都独占一个 WebSocket frame。客户端不得(MUST NOT)为同一会话打开多条 WebSocket 连接,否则服务器必须关闭该连接。
WebTransport
WebTransport 传输基于 WebTransport 双向流(HTTP/3 之上的现代传输),同样提供双向低延迟通道,并额外支持多流与无序投递。关键规则:客户端不得为同一会话打开多条 WebTransport 双向流,否则服务器必须关闭该 WebTransport 会话。
需要注意协议文档中的一点说明:当前版本的协议不使用查询参数,因为查询参数不属于 WebTransport 规范,只是实现细节,未来可能会变化。这与源码一致:WebTransport 传输 构造时直接写死 super({ _query: { EIO: "4" } }),且服务端的 onWebTransportSession() 不走 verify() 流程,而是等待客户端先建立双向流并在 upgradeTimeout 内发送 open 包。由于 HTTP 中间件无法从 WebTransport 会话对象构造 IncomingMessage,配置了 middlewares 的服务器会直接关闭 WebTransport 会话。
包模型:类型与 ID
一个 Engine.IO 包由包类型与可选载荷组成。可用的包类型如下:
| 类型 | ID | 用途 |
|---|---|---|
| open | 0 | 用于握手。 |
| close | 1 | 表示传输可以关闭。 |
| ping | 2 | 用于心跳机制。 |
| pong | 3 | 用于心跳机制。 |
| message | 4 | 向对端发送载荷。 |
| upgrade | 5 | 用于升级过程。 |
| noop | 6 | 用于升级过程。 |
该映射在参考实现中由 commons.ts 的 PACKET_TYPES 表维护,编码时(encodePacket)把类型名替换为对应的一字节能 ID 前缀。
握手
建立连接时,客户端必须向服务器发送 HTTP GET 请求,有三种形式:
1)先走 HTTP long-polling(默认)
CLIENT SERVER
│ │
│ GET /engine.io/?EIO=4&transport=polling │
│ ───────────────────────────────────────────────────────► │
│ ◄──────────────────────────────────────────────────────┘ │
│ HTTP 200 │
│ │
2)仅 WebSocket 的会话
CLIENT SERVER
│ │
│ GET /engine.io/?EIO=4&transport=websocket │
│ ───────────────────────────────────────────────────────► │
│ ◄──────────────────────────────────────────────────────┘ │
│ HTTP 101 Switching Protocols │
│ │
3)仅 WebTransport 的会话:客户端必须先在双向流上发送 open 包。
CLIENT SERVER
│ (WebTransport session + bidirectional stream) │
│ ───────────────────────────────────────────────────────► │
│ │
│ 0 (open packet) │
│ ───────────────────────────────────────────────────────► │
│ ◄────────────────────────────────────────────────────── │
│ 0{"sid":"..."} │
│ │
服务器接受连接后,必须(MUST)响应一个 open 包,其载荷为如下 JSON:
| 键 | 类型 | 说明 |
|---|---|---|
sid |
string |
会话 ID。 |
upgrades |
string[] |
可用的传输升级列表。 |
pingInterval |
number |
心跳使用的 ping 间隔(毫秒)。 |
pingTimeout |
number |
心跳使用的 ping 超时(毫秒)。 |
maxPayload |
number |
单个 chunk 的最大字节数,客户端据此把包聚合为载荷。 |
示例:
{
"sid": "lv_VI97HAXpY6yYWAAAC",
"upgrades": ["websocket"],
"pingInterval": 25000,
"pingTimeout": 20000,
"maxPayload": 1000000
}
这组默认值与服务端 Server 构造函数的默认配置 一一对应:pingTimeout: 20000、pingInterval: 25000、maxHttpBufferSize: 1e6。open 包的实际发送逻辑在 Socket.onOpen():
this.sendPacket(
"open",
JSON.stringify({
sid: this.id,
upgrades: this.getAvailableUpgrades(),
pingInterval: this.server.opts.pingInterval,
pingTimeout: this.server.opts.pingTimeout,
maxPayload: this.server.opts.maxHttpBufferSize,
}),
);
其中 upgrades 由 Server.upgrades() 计算:若配置了 allowUpgrades: false(默认 true)则返回空数组,否则返回当前传输可升级到的传输列表。握手完成后,客户端必须在后续所有请求的查询参数中携带 sid。sid 由 generateId() 生成(默认使用 base64 随机 ID),并且可以被覆写以生成自定义 ID。
心跳
握手完成后,心跳机制启动以检查连接存活:
CLIENT SERVER
│ *** Handshake *** │
│ │
│ ◄───────────────────────────────────────────────── │
│ 2 │ (ping packet)
│ ─────────────────────────────────────────────────► │
│ 3 │ (pong packet)
在固定间隔(即握手时下发的 pingInterval)到达时,服务器发送 ping 包,客户端需在若干秒内(pingTimeout 值)回发 pong 包:
- 若服务器未收到
pong,应当(SHOULD)认为连接已关闭。源码中 resetPingTimeout() 在pingTimeout到期后调用onClose("ping timeout"); - 反之,若客户端在
pingInterval + pingTimeout内未收到ping,也应当认为连接已关闭。
v4 的一个关键设计是 ping 由服务器发起(详见下文版本历史),服务端 Socket.onOpen() 中有明确分支:协议 v3 时客户端发 ping、服务器发 pong;协议 v4 则调用 schedulePing() 由服务器计时发 ping。服务端收到客户端的 ping(v4 下方向错误)会直接报错断开,见 onPacket()。
传输升级
默认情况下,客户端应当(SHOULD)先建立 HTTP long-polling 连接,随后在有更好的传输时升级。升级(到 WebSocket 或 WebTransport)时,客户端必须:
- 暂停 HTTP long-polling 传输(不再发送 HTTP 请求),确保不丢包;
- 用相同的会话 ID 打开 WebSocket 连接(或 WebTransport 双向流);
- 发送一个载荷为字符串
probe的ping包。
服务器必须:
- 向任何挂起的
GET请求发送noop包(如适用),以干净地结束 long-polling 传输; - 回应一个载荷为
probe的pong包。
最后,客户端必须发送 upgrade 包完成升级:
CLIENT SERVER
│ │
│ GET /engine.io/?EIO=4&transport=websocket&sid=... │
│ ───────────────────────────────────────────────────► │
│ ◄─────────────────────────────────────────────────┘ │
│ HTTP 101 (WebSocket handshake) │
│ │
│ ----- WebSocket frames ----- │
│ ─────────────────────────────────────────────────► │
│ 2probe │ (ping packet)
│ ◄───────────────────────────────────────────────── │
│ 3probe │ (pong packet)
│ ─────────────────────────────────────────────────► │
│ 5 │ (upgrade packet)
│ │
参考实现 Socket._maybeUpgrade() 与文档逐条对应:
- 收到
ping且载荷为probe时,回pong(probe)并触发upgrading事件; - 之后每 100ms 检查一次旧 polling 传输是否可写,可写就向其写入一个
noop包,使挂起的 GET 请求尽快返回(即文档中“向挂起 GET 请求发 noop 包”的实现); - 收到
upgrade包后丢弃旧传输、切换新传输并flush()写缓冲区; - 整个升级过程受
upgradeTimeout(默认 10000ms)保护,超时则关闭新传输。
消息
握手完成后,客户端与服务器即可通过在 message 包中携带数据交换内容。服务端对 message 包的处理在 onPacket() 中:同时触发 data 与 message 事件并转发 packet.data。
包编码
Engine.IO 包的序列化取决于载荷类型(纯文本或二进制)与传输方式。纯文本与 base64 编码的二进制载荷均使用 UTF-8。
HTTP long-polling
由于 long-polling 的特性,多个包可以拼接在同一个载荷中以提升吞吐。格式为:
<packet type>[<data>]<separator><packet type>[<data>]<separator><packet type>[<data>][...]
示例:
4hello\x1e2\x1e4world
其中:
4 => message 包类型
hello => 消息载荷
\x1e => 分隔符
2 => ping 包类型
\x1e => 分隔符
4 => message 包类型
world => 消息载荷
包之间的分隔符是记录分隔符(record separator)字符 \x1e,源码中即 SEPARATOR = String.fromCharCode(30),编码与解码分别由 encodePayload() 与 decodePayload() 实现(解码时遇到 error 包即中止后续解析)。
二进制载荷必须 base64 编码,并以 b 字符作前缀。示例:
4hello\x1ebAQIDBA==
其中:
4 => message 包类型
hello => 消息载荷
\x1e => 分隔符
b => 二进制前缀
AQIDBA== => buffer <01 02 03 04> 的 base64 编码
b 前缀的拼接在 encodePacket() 中完成(不支持二进制时输出 "b" + base64);而 long-polling 场景的 encodePayload() 强制以 supportsBinary = false 调用它——这正是 v4 规范“长轮询下始终使用 base64”的代码体现。
客户端应当(SHOULD)使用握手时下发的 maxPayload 值来决定拼接多少个包。
WebSocket
每个 Engine.IO 包独占一个 WebSocket frame。格式为:
<packet type>[<data>]
示例:
4hello
其中:
4 => message 包类型
hello => 消息载荷(UTF-8 编码)
二进制载荷原样发送,不做任何变换。
WebTransport
WebTransport 是流式传输,因此载荷之前会先发送一个描述载荷的头部(header),其结构高度借鉴了 WebSocket 分帧。头部长度取决于载荷长度:
| 载荷长度(字节) | 头部长度(字节) | 细节 |
|---|---|---|
<= 125 |
1 | x + 7 位长度编码 |
> 125 且 <= 65535 |
3 | x1111110 + 2 字节长度编码 |
> 65535 |
9 | x1111111 + 8 字节长度编码 |
x 位表示载荷是纯文本(0)还是二进制(1)数据。示例:socket.send("hello") 会发送为:
header: buffer <06>
其中:
0 => 纯文本载荷
0000110 => 6 字节
payload: buffer <34 68 65 6c 6c 6f>
其中:
0x34 = ASCII "4",即 Engine.IO MESSAGE 包类型
0x68 = "h"
0x65 = "e"
0x6c = "l"
0x6c = "l"
0x6f = "o"
注意 payload 的开头字节 0x34 即 ASCII 字符 "4",说明 WebTransport 上传输的仍然是“类型 ID + 数据”的编码包,头部只负责描述长度与文本/二进制属性。服务端实现 createPacketEncoderStream() 与规范逐条对应:长度 < 126 时写 1 字节头;< 65536 时首字节写 126(即 01111110,保留首位给二进制标记)加 2 字节长度;更大则首字节 127 加 8 字节长度;二进制包通过 header[0] |= 0x80 置位首个比特。解码端 createPacketDecoderStream() 以状态机(读头部 / 读 16 位扩展长度 / 读 64 位扩展长度 / 读载荷)跨 chunk 还原包,并在长度超过 maxPayload 或为 0 时产生 error 包——8 字节长度还可能超过 JavaScript 安全整数上限(2^53-1),此时同样返回错误包,这是对规范的健壮性补充。
版本历史
v2 到 v3
- 新增二进制数据支持。
协议第 2 版用于 Socket.IO v0.9 及更早版本;第 3 版用于 Socket.IO v1 与 v2。
v3 到 v4
- 反转 ping/pong 机制:ping 改由服务器发送。原因是浏览器中设置的定时器不够可靠,大量超时问题被怀疑源于客户端计时器被延迟;
- 编码含二进制数据的载荷时始终使用 base64:这样无论客户端或当前传输是否支持二进制,都可以用同一种方式处理所有载荷。注意这仅适用于 HTTP long-polling,WebSocket frame 中的二进制数据不做任何变换直接发送;
- 用记录分隔符(
\x1e)代替按字符计数:按字符计数给其他语言(可能不使用 UTF-16 编码)的实现造成了困难。例如€曾被编码为2:4€,而Buffer.byteLength('€') === 3。该方案的前提是记录分隔符不出现在数据中。
第 4 版自 Socket.IO v3.0.0(2020 年 11 月)起引入。
v4 到 v4.1
- 新增 WebTransport 支持。
第 4.1 版自 Socket.IO v4.6.0(2023 年 6 月)起引入。仓库中对应的实现位于 transports/webtransport.ts(服务端)与 engine.io-client 的 WebTransport 传输(客户端)。
合规测试套件
仓库在 v4-test-suite 目录提供了针对 v4 协议的测试套件,可用于校验一个服务器实现是否符合规范(v3 版本另有 v3-test-suite)。使用方式:
- Node.js:
npm ci && npm test; - 浏览器:直接用浏览器打开
index.html文件即可。
文档给出的、能让 JavaScript 服务器通过全部测试的预期参考配置:
import { listen } from "engine.io";
const server = listen(3000, {
pingInterval: 300,
pingTimeout: 200,
maxPayload: 1e6,
cors: {
origin: "*"
}
});
server.on("connection", socket => {
socket.on("data", (...args) => {
socket.send(...args);
});
});
可以看到该配置把心跳间隔压缩到 300ms/200ms 以加速测试,maxPayload 保持默认的 1MB,并通过 CORS 允许任意来源。若自行实现非 JavaScript 的 Engine.IO 服务端,建议:1)以本文的类型 ID 表与包编码规则为验收标准;2)对照 engine.io-parser 的单元测试 中的编解码用例校验自己的序列化逻辑;3)参考服务端 test 目录 中对手握、心跳、升级、polling 重叠请求等行为的断言,覆盖上述“必须/应当”条款。
小结
Engine.IO 协议 v4.1 用一份很短但严谨的规范定义了实时通信的传输基础:三种传输方式各有明确的请求路径、查询参数约束与错误码(400 覆盖参数缺失、未知 sid、请求重叠等场景);握手一次性下发 sid、upgrades、pingInterval、pingTimeout、maxPayload 五项会话参数;心跳由服务器发起 ping 以避免浏览器定时器不可靠;传输升级用 probe ping/pong 加 noop、upgrade 包完成无损切换;包编码则按传输方式分别采用 \x1e 分隔的 base64 拼接(long-polling)、独立 frame(WebSocket)与借鉴 WebSocket 分帧的长度头(WebTransport)。这些规则与仓库中 engine.io、engine.io-parser 的参考实现一一吻合,可作为实现或校验任意语言兼容端的第一手依据。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00