Playwright WebSocketRoute 深度指南:WebSocket 路由、Mock、拦截与源码实现
本文围绕 Playwright 的 WebSocketRoute 类 API(自 v1.48 引入)展开,系统讲解如何通过 page.routeWebSocket() / browserContext.routeWebSocket() 对页面中的 WebSocket 连接进行完整 Mock、消息拦截与双向转发控制,并结合 WebSocketRoute 客户端实现、WebSocketRouteDispatcher 分发器实现 与 route-web-socket 测试套件 剖析其底层事件通道、二进制消息编解码与连接生命周期管理。读完本文,你可以独立搭建可复现的 WebSocket 服务端 Mock,并在真实服务器上实现消息改写、阻断与协议协商控制。
一、WebSocketRoute 是什么:路由设置后的“服务端替身”
在 Playwright 中,每当通过 Page.routeWebSocket 或 BrowserContext.routeWebSocket 设置了一条 WebSocket 路由,对应的 WebSocketRoute 对象就会允许你像真正的服务端一样处理该 WebSocket 连接。原始 API 文档见 class-websocketroute.md。
两条入口方法的签名在源码中定义如下:
- JS/TypeScript(page.ts):
await page.routeWebSocket(url, handler);
// url: URLMatch(string 支持 glob / RegExp / function)
// handler: (ws: WebSocketRoute) => void | Promise<void>
- Java:
page.routeWebSocket(url, handler);Python:page.route_web_socket(url, handler);.NET:page.RouteWebSocketAsync(url, handler)。
从源码结构看,routeWebSocket 的核心行为是:将新的 WebSocketRouteHandler 以 unshift 插入路由列表头部(后注册的规则优先匹配),随后调用 _updateWebSocketInterceptionPatterns 把 URL 匹配模式下发到浏览器端(见 page.ts 与 browserContext.ts)。BrowserContext 级别的路由则作用于该上下文内所有页面。
二、Mock:不连接真实服务器,模拟整条 WebSocket 通信
默认情况下,被路由的 WebSocket 不会连接到真实服务器。因此你可以完整地 Mock 掉 WebSocket 通信。下面是一个响应 "request" 消息并回复 "response" 的完整示例(继承自原文档,覆盖四种语言):
await page.routeWebSocket('wss://example.com/ws', ws => {
ws.onMessage(message => {
if (message === 'request')
ws.send('response');
});
});
page.routeWebSocket("wss://example.com/ws", ws -> {
ws.onMessage(frame -> {
if ("request".equals(frame.text()))
ws.send("response");
});
});
def message_handler(ws: WebSocketRoute, message: Union[str, bytes]):
if message == "request":
ws.send("response")
await page.route_web_socket("wss://example.com/ws", lambda ws: ws.on_message(
lambda message: message_handler(ws, message)
))
def message_handler(ws: WebSocketRoute, message: Union[str, bytes]):
if message == "request":
ws.send("response")
page.route_web_socket("wss://example.com/ws", lambda ws: ws.on_message(
lambda message: message_handler(ws, message)
))
await page.RouteWebSocketAsync("wss://example.com/ws", ws => {
ws.OnMessage(frame => {
if (frame.Text == "request")
ws.Send("response");
});
});
为什么不调用 connectToServer 就是 Mock? 在 network.ts 中可以看到,路由处理函数返回后,客户端会执行 _afterHandle():只要没有 connectToServer(this._connected 为 false),就调用 ensureOpened 通道命令,让注入到页面里的 mock 脚本把 WebSocket 直接标记为“已打开”状态,从而保证页面里的 ws.send() 不会因没有真实连接而抛错:
async _afterHandle() {
if (this._connected)
return;
// Ensure that websocket is "open" and can send messages without an actual server connection.
// If this happens after the page has been closed, ignore the error.
await this._channel.ensureOpened({}, kNoTimeout).catch(() => {});
}
这也是官方文档所述“Playwright assumes that WebSocket will be mocked, and opens the WebSocket inside the page automatically”的实现依据。
处理 JSON 消息
Mock 场景中最常见的是结构化消息,原文档给出了 JSON 处理示例:
await page.routeWebSocket('wss://example.com/ws', ws => {
ws.onMessage(message => {
const json = JSON.parse(message);
if (json.request === 'question')
ws.send(JSON.stringify({ response: 'answer' }));
});
});
page.routeWebSocket("wss://example.com/ws", ws -> {
ws.onMessage(frame -> {
JsonObject json = new JsonParser().parse(frame.text()).getAsJsonObject();
if ("question".equals(json.get("request").getAsString())) {
Map<String, String> result = new HashMap();
result.put("response", "answer");
ws.send(gson.toJson(result));
}
});
});
def message_handler(ws: WebSocketRoute, message: Union[str, bytes]):
json_message = json.loads(message)
if json_message["request"] == "question":
ws.send(json.dumps({ "response": "answer" }))
await page.route_web_socket("wss://example.com/ws", lambda ws: ws.on_message(
lambda message: message_handler(ws, message)
))
await page.RouteWebSocketAsync("wss://example.com/ws", ws => {
ws.OnMessage(frame => {
using var jsonDoc = JsonDocument.Parse(frame.Text);
JsonElement root = jsonDoc.RootElement;
if (root.TryGetProperty("request", out JsonElement requestElement) && requestElement.GetString() == "question")
{
var response = new Dictionary<string, string> { ["response"] = "answer" };
string jsonResponse = JsonSerializer.Serialize(response);
ws.Send(jsonResponse);
}
});
});
语言差异提示:JS 与 Python 的 onMessage handler 直接接收原始消息(string | bytes/Buffer);而 Java 与 C# 的 handler 接收的是 WebSocketFrame 对象,需要调用 frame.text() / frame.Text 取文本载荷、frame.binary() / frame.Binary 取二进制载荷,其定义见 class-websocketframe.md(该类自 v1.9 起存在,仅用于 C# 与 Java)。
三、Intercepting:connectToServer 之后的双向转发与拦截
如果你希望连接真实服务器,但想在中间截获并修改或阻断消息,可以调用 WebSocketRoute.connectToServer。它返回一个“服务端侧”的 WebSocketRoute 实例,你可以向它发消息或处理来自服务器的消息。
修改页面发给服务器的消息
以下示例改写页面上行消息,而服务器下行消息保持默认转发:
await page.routeWebSocket('/ws', ws => {
const server = ws.connectToServer();
ws.onMessage(message => {
if (message === 'request')
server.send('request2');
else
server.send(message);
});
});
page.routeWebSocket("/ws", ws -> {
WebSocketRoute server = ws.connectToServer();
ws.onMessage(frame -> {
if ("request".equals(frame.text()))
server.send("request2");
else
server.send(frame.text());
});
});
def message_handler(server: WebSocketRoute, message: Union[str, bytes]):
if message == "request":
server.send("request2")
else:
server.send(message)
def handler(ws: WebSocketRoute):
server = ws.connect_to_server()
ws.on_message(lambda message: message_handler(server, message))
await page.route_web_socket("/ws", handler)
def message_handler(server: WebSocketRoute, message: Union[str, bytes]):
if message == "request":
server.send("request2")
else:
server.send(message)
def handler(ws: WebSocketRoute):
server = ws.connect_to_server()
ws.on_message(lambda message: message_handler(server, message))
page.route_web_socket("/ws", handler)
await page.RouteWebSocketAsync("/ws", ws => {
var server = ws.ConnectToServer();
ws.OnMessage(frame => {
if (frame.Text == "request")
server.Send("request2");
else
server.Send(frame.Text);
});
});
转发语义:onMessage 会“接管”某个方向
连接服务器之后,所有消息默认在页面与服务器之间自动双向转发。但存在两条明确的规则(原文档核心要点,也与源码实现一一对应):
- 在原始路由上调用
onMessage后,页面到服务器的消息不再自动转发,必须由 handler 自行处理(例如调用server.send显式转发); - 在服务端路由上调用
onMessage后,服务器到页面的消息不再自动转发,必须由 handler 接管。
这两条规则可以直接在 network.ts 的通道事件处理中得到印证:
this._channel.on('messageFromPage', ({ message, isBase64 }) => {
if (this._onPageMessage)
this._onPageMessage(isBase64 ? Buffer.from(message, 'base64') : message);
else if (this._connected)
this._channel.sendToServer({ message, isBase64 }, kNoTimeout).catch(() => {});
});
this._channel.on('messageFromServer', ({ message, isBase64 }) => {
if (this._onServerMessage)
this._onServerMessage(isBase64 ? Buffer.from(message, 'base64') : message);
else
this._channel.sendToPage({ message, isBase64 }, kNoTimeout).catch(() => {});
});
即:有 handler 走 handler,没有 handler 且已连接则自动转发到对端。关闭事件(closePage/closeServer)也遵循同样的“handler 优先,否则自动向对端传播”模式。
双向阻断消息
下面这个示例在两个方向都调用 onMessage,因此完全不保留自动转发,用于阻断特定消息:
await page.routeWebSocket('/ws', ws => {
const server = ws.connectToServer();
ws.onMessage(message => {
if (message !== 'blocked-from-the-page')
server.send(message);
});
server.onMessage(message => {
if (message !== 'blocked-from-the-server')
ws.send(message);
});
});
page.routeWebSocket("/ws", ws -> {
WebSocketRoute server = ws.connectToServer();
ws.onMessage(frame -> {
if (!"blocked-from-the-page".equals(frame.text()))
server.send(frame.text());
});
server.onMessage(frame -> {
if (!"blocked-from-the-server".equals(frame.text()))
ws.send(frame.text());
});
});
def ws_message_handler(server: WebSocketRoute, message: Union[str, bytes]):
if message != "blocked-from-the-page":
server.send(message)
def server_message_handler(ws: WebSocketRoute, message: Union[str, bytes]):
if message != "blocked-from-the-server":
ws.send(message)
def handler(ws: WebSocketRoute):
server = ws.connect_to_server()
ws.on_message(lambda message: ws_message_handler(server, message))
server.on_message(lambda message: server_message_handler(ws, message))
await page.route_web_socket("/ws", handler)
await page.RouteWebSocketAsync("/ws", ws => {
var server = ws.ConnectToServer();
ws.OnMessage(frame => {
if (frame.Text != "blocked-from-the-page")
server.Send(frame.Text);
});
server.OnMessage(frame => {
if (frame.Text != "blocked-from-the-server")
ws.Send(frame.Text);
});
});
注意 Python 侧的函数参数命名要刻意区分两个 WebSocketRoute(原始路由与服务端路由),否则闭包内会引用错对象。
四、API 方法逐项详解
以下各方法均自 v1.48 引入(protocols 除外,见第六节),与 class-websocketroute.md 保持一一对应。
close(options):关闭 WebSocket 连接的一侧
WebSocketRoute.close 关闭 WebSocket 连接的一侧,接受两个可选参数:
| 参数 | 类型 | 说明 |
|---|---|---|
code |
int |
可选的 WebSocket 关闭码 |
reason |
string |
可选的关闭原因文本 |
从 源码 看,在原始路由(页面侧)上调用 close() 实际会向 closePage 通道发送 { code, reason, wasClean: true };而在服务端路由上调用则走 closeServer 通道。
connectToServer():建立到真实服务器的连接
- 返回:服务端侧的
WebSocketRoute实例,可向它发消息或处理来自服务器的消息。 - 默认被路由的 WebSocket 不连接服务器,以便 Mock 整条通信;调用此方法后即连接真实服务器。
- 连接后的转发行为:服务器消息自动转发到页面(除非在服务端路由上调用了
onMessage);页面WebSocket.send()的消息自动转发到服务器(除非在原始路由上调用了onMessage)。 - 重复调用会抛错:源码 中
connectToServer()先检查this._connected,若已连接则抛出Already connected to the server,对应测试用例should throw when connecting twice(route-web-socket.spec.ts)。 - 服务端路由上的
connectToServer不可用,客户端会直接抛出connectToServer must be called on the page-side WebSocketRoute(见 network.ts),这是对 API 使用方向的源码级保护。
onMessage(handler):接管某一方向的消息
- 在原始路由上调用:处理页面发出的消息。你可以用
send直接应答页面、用connectToServer返回的服务端连接转发、或做其他处理。 - 一旦调用,消息不再自动转发到服务器或页面,需要手动调用
send。 - 重复调用会覆盖旧 handler(源码中 handler 以单一字段
_onPageMessage/_onServerMessage存储,直接赋值覆盖,见 network.ts)。 - handler 签名按语言区分:
- JS / Python:
handler(message: string | Buffer) => any,直接接收原始消息; - C# / Java:
handler(frame: WebSocketFrame),通过text()/binary()区分文本帧与二进制帧。
- JS / Python:
send(message):向 WebSocket 发送消息
message 类型为 string | Buffer。在原始路由上调用会把消息发给页面;在 connectToServer 返回的服务端路由上调用则发给服务器。
二进制编解码在 源码 中处理:字符串消息以 isBase64: false 直接透传;Buffer 消息先 toString('base64') 再发送,接收侧再按 isBase64 标志还原为 Buffer。这意味着 send 天然支持文本帧与二进制帧两种载荷。
onClose(handler):处理 WebSocket 关闭
onClose 用于处理 WebSocket.close 事件。默认行为是:连接任一侧(页面或服务器)关闭时,另一侧也会被自动关闭;一旦设置了 onClose handler,默认的关闭转发被禁用,由 handler 自行决定是否向对端传播关闭。handler 接收可选的关闭码与关闭原因:
- JS / Python:
(code?: int, reason?: string) => any; - Java:
(Integer code, String reason)(可能为null); - C#:
(int? code, string? reason)。
url():页面中创建的 WebSocket 的 URL
返回字符串,即路由命中的 WebSocket 实际 URL。实现上直接读取初始化数据 this._initializer.url(见 network.ts)。
五、路由匹配机制与底层注入原理
客户端:WebSocketRouteHandler 与模式匹配
WebSocketRouteHandler 封装了 routeWebSocket 的 URL 与 handler:
- glob 模式提前校验:构造函数中若
url是字符串,会立即调用resolveGlobToRegexPattern验证,使非法模式在page.routeWebSocket()调用点就抛错,而不是延迟到连接时才失败(源码注释明确说明了这一设计意图); - baseURL 支持:相对 URL(如
/ws)会结合 context 的baseURL解析,测试用例should work with relative WebSocket URL与should work with baseURL覆盖了这一行为; - 模式序列化下发:
prepareInterceptionPatterns把所有 handler 的 URL 模式序列化为浏览器端拦截模式;若任一 handler 的模式为空(匹配全部),则整体下发**/*。
服务端:WebSocketRouteDispatcher 与页面注入
webSocketRouteDispatcher.ts 揭示了完整的拦截链路:
- 安装拦截(
install静态方法):在BrowserContext上通过exposeBinding('__pwWebSocketBinding')注入页面通信入口,并通过addInitScript注入由@injected/webSocketMock生成的 mock 脚本(即页面内对WebSocket构造函数的劫持)。页面中的 WebSocket 创建事件会携带id、url、protocols上报。 - 路由归属判断:收到
onCreate后,服务端先用matchesPattern依次尝试页面级与上下文级的拦截模式(_webSocketInterceptionPatterns);命中则创建WebSocketRouteDispatcher并通知客户端派发 handler;未命中则下发passthrough,让页面直连真实服务器。 - 消息转发:页面消息(
onMessageFromPage)、服务器消息(onMessageFromServer)、两侧关闭事件(onClosePage/onCloseServer)都通过 binding 上报到 dispatcher,再由客户端WebSocketRoute按“handler 优先、否则自动转发”的规则处理。 - 执行上下文失效保护:dispatcher 监听页面/框架的
InternalFrameNavigatedToNewDocument、FrameDetached、Page.Close、Page.Crash事件,一旦发生,认为 mock WebSocket 已无通信能力,会派发closePage/closeServer(wasClean: true),对应测试should emit close upon frame navigation、should emit close upon frame detach与should not throw after page closure。 - 单客户端约束:
install中若发现同一 context 已有其他连接在路由 WebSocket,会抛出Another client is already routing WebSockets——即一个浏览器上下文的 WebSocket 路由只能由一个 Playwright 连接管理。
六、protocols():自 v1.60 起的子协议协商
WebSocketRoute.protocols 自 v1.60 引入,返回页面通过 WebSocket 构造函数 第二个参数请求的子协议列表,对应 Sec-WebSocket-Protocol 请求头;未指定任何协议时返回空数组。服务端路由上同样可以读取(源码中 protocols() 在原始路由与服务端侧对象上均实现了相同的 [...this._initializer.protocols] 逻辑,见 network.ts 与 L545-L547)。
典型用法是根据子协议选择处理策略,不支持则用标准关闭码 1002(Protocol Error)关闭:
await page.routeWebSocket('wss://example.com/ws', ws => {
if (ws.protocols().includes('chat.v2'))
ws.onMessage(message => ws.send(JSON.stringify({ version: 2, echo: message })));
else
ws.close({ code: 1002, reason: 'Unsupported protocol' });
});
page.routeWebSocket("wss://example.com/ws", ws -> {
if (ws.protocols().contains("chat.v2")) {
ws.onMessage(frame -> ws.send("v2:" + frame.text()));
} else {
ws.close(1002, "Unsupported protocol");
}
});
async def handler(ws: WebSocketRoute):
if "chat.v2" in ws.protocols:
ws.on_message(lambda message: ws.send(f"v2:{message}"))
else:
await ws.close(code=1002, reason="Unsupported protocol")
await page.route_web_socket("wss://example.com/ws", handler)
def handler(ws: WebSocketRoute):
if "chat.v2" in ws.protocols:
ws.on_message(lambda message: ws.send(f"v2:{message}"))
else:
ws.close(code=1002, reason="Unsupported protocol")
page.route_web_socket("wss://example.com/ws", handler)
await page.RouteWebSocketAsync("wss://example.com/ws", ws => {
if (ws.Protocols.Contains("chat.v2"))
ws.OnMessage(frame => ws.Send($"v2:{frame.Text}"));
else
ws.CloseAsync(new() { Code = 1002, Reason = "Unsupported protocol" });
});
注意 Python 的 protocols 是属性(ws.protocols)而非方法,与其他语言的方法调用形式不同;真实连接时,测试用例 should pass through the required protocol 验证了协议会在握手时正确透传给服务器。
七、测试用例佐证的行为边界
tests/library/route-web-socket.spec.ts 为上述文档语义提供了完整的行为验证,可以据此确认适用边界:
| 测试用例 | 验证的语义 |
|---|---|
should work with text message |
文本消息往返 |
should work with binaryType=blob / arraybuffer |
页面不同 binaryType 下二进制消息正确到达 handler |
should work when connection errors out |
上游连接失败时的行为 |
should work with client-side close |
页面侧 ws.close 的关闭传播 |
should observe upstream handshake failure when connectToServer is used |
拦截模式下握手失败可被观察 |
should observe multiple concurrent routed WebSockets with connectToServer |
多个并发路由连接互不串扰(依赖 dispatcher 的 _idToDispatcher 映射) |
should pattern match |
URL 模式匹配(glob) |
should emit close upon frame navigation / frame detach |
帧导航/分离时自动派发 close |
should route on context |
BrowserContext.routeWebSocket 生效于上下文内页面 |
should not throw when connecting twice 的对应用例 |
二次 connectToServer 抛错 |
should work with baseURL / baseURL regardless of scheme casing |
相对 URL 与大小写不敏感匹配 |
should expose protocols to the route handler / on server-side route |
protocols 在两侧均可用 |
此外,tests/library/multiclient.spec.ts 验证了多客户端场景下路由的共存约束,与 dispatcher 中 “Another client is already routing WebSockets” 的源码约束相呼应。
八、实用建议与限制总结
- Mock 与拦截的切换点只有一个:是否在 handler 中调用
connectToServer。不调用则 Playwright 自动把页面内 WebSocket 置为打开态(ensureOpened),整条链路脱离真实服务器;调用了则进入真实握手,默认双向转发,onMessage/onClose按方向逐步接管。 - handler 必须显式转发:一旦在某一方向设置了
onMessage,该方向消息即停止自动转发,忘记调用对端send会导致消息静默丢失(send内部.catch(() => {})吞掉了传输错误,不会主动报错)。 - 适用前提:
routeWebSocket需要 v1.48+;protocols需要 v1.60+;Java/C# 的消息载荷需经WebSocketFrame间接读取;同一浏览器上下文的 WebSocket 路由仅能由一个 Playwright 客户端连接建立。 - 生命周期注意:页面导航、帧分离或页面关闭都会触发路由侧的关闭派发,编写测试时不应假设导航后 mock 连接仍然存活。
综合来看,WebSocketRoute 以“页面侧路由 + 服务端侧路由”的双实例模型,配合按方向可接管的转发规则,覆盖了从纯 Mock 到真实链路上的细粒度拦截的完整光谱,是 Playwright 处理实时通信类页面测试与自动化(如聊天协议、行情推送、协同编辑同步)的核心能力。
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 StartedRust0625
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