首页
/ Engine.IO 协议 v4 深度解析:握手、心跳、传输升级与包编码规范

Engine.IO 协议 v4 深度解析:握手、心跳、传输升级与包编码规范

2026-09-03 16:42:10作者:宣聪麟

本文基于 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 编写,即本仓库中的:

上层的 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_TRANSPORTsid 存在但查不到对应会话返回 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.tsres.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.tsPACKET_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: 20000pingInterval: 25000maxHttpBufferSize: 1e6open 包的实际发送逻辑在 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,
  }),
);

其中 upgradesServer.upgrades() 计算:若配置了 allowUpgrades: false(默认 true)则返回空数组,否则返回当前传输可升级到的传输列表。握手完成后,客户端必须在后续所有请求的查询参数中携带 sidsidgenerateId() 生成(默认使用 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)时,客户端必须:

  1. 暂停 HTTP long-polling 传输(不再发送 HTTP 请求),确保不丢包;
  2. 用相同的会话 ID 打开 WebSocket 连接(或 WebTransport 双向流);
  3. 发送一个载荷为字符串 probeping 包。

服务器必须:

  1. 向任何挂起的 GET 请求发送 noop 包(如适用),以干净地结束 long-polling 传输;
  2. 回应一个载荷为 probepong 包。

最后,客户端必须发送 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 时,回 pongprobe)并触发 upgrading 事件;
  • 之后每 100ms 检查一次旧 polling 传输是否可写,可写就向其写入一个 noop 包,使挂起的 GET 请求尽快返回(即文档中“向挂起 GET 请求发 noop 包”的实现);
  • 收到 upgrade 包后丢弃旧传输、切换新传输并 flush() 写缓冲区;
  • 整个升级过程受 upgradeTimeout(默认 10000ms)保护,超时则关闭新传输。

消息

握手完成后,客户端与服务器即可通过在 message 包中携带数据交换内容。服务端对 message 包的处理在 onPacket() 中:同时触发 datamessage 事件并转发 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 v1v2

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、请求重叠等场景);握手一次性下发 sidupgradespingIntervalpingTimeoutmaxPayload 五项会话参数;心跳由服务器发起 ping 以避免浏览器定时器不可靠;传输升级用 probe ping/pong 加 noopupgrade 包完成无损切换;包编码则按传输方式分别采用 \x1e 分隔的 base64 拼接(long-polling)、独立 frame(WebSocket)与借鉴 WebSocket 分帧的长度头(WebTransport)。这些规则与仓库中 engine.ioengine.io-parser 的参考实现一一吻合,可作为实现或校验任意语言兼容端的第一手依据。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
981
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384