Web 服务监听与访问令牌
packages/server/src/http.ts 集中实现 Web 服务的 HTTP/WebSocket 接入基础,包括监听选项、服务身份与工作区发现、访问令牌保护,以及 WebSocket 到 RPC 通道的适配。本页聚焦服务端 Web 入口与令牌边界;远程连接后端、部署策略和具体业务服务不在本页展开。
Purpose and Scope
本页说明以下已在源码中确认的能力:
HttpServerOptions暴露的服务标识、名称、主机、鉴权、令牌、SPA 静态资源与工作区配置。ZCODE_SERVER_ID、ZCODE_SERVER_NAME、ZCODE_SERVER_TOKEN、ZCODE_SERVER_WORKSPACE环境变量如何参与默认值解析。createServerInfo如何生成服务发现信息,包括协议版本、能力标记和工作区列表。- WebSocket 如何包装为
@zcode/rpc所需的ISocket,以及如何建立ChannelServer。 - Web 模式令牌通过 URL 查询参数首次进入、再写入 HttpOnly Cookie,并用于保护
/ws、/api/路径的机制。
本页不替代远程后端、Agent 会话业务、UI 登录流程或部署文档。源码摘录显示了 HTTP 层的令牌和 RPC 边界,但当前受限的源码读取窗口未覆盖 http.ts 后半段的完整路由注册与监听调用,因此未对具体 HTTP 方法、端口默认值或完整响应状态码作推断。
Overview
Web 入口承担两类边界职责:
- 发现与配置边界:从显式
HttpServerOptions或环境变量解析服务身份和工作区,生成ServerRemoteInfo。显式workspaces优先于环境变量和当前工作目录;服务 ID 则按显式值、ZCODE_SERVER_ID、主机名、zcode-server的顺序回退。 - 传输与权限边界:将 WebSocket 字节流映射为 RPC 的
ISocket,再通过SocketProtocol、ChannelServer和LoggingChannelServer暴露服务。令牌保护路径由isTokenProtectedPath识别,已读取到的实现表明/ws、/ws/...和/api/...属于受保护范围。
令牌设计采用“查询参数引导、Cookie 复用”的方式:请求携带正确的 token 查询参数时,服务端写入 zcode_lite_token Cookie;后续请求可直接通过 Cookie 校验,从而避免每个请求都必须在 URL 中重复携带令牌。Cookie 设置了 HttpOnly 和 SameSite=Lax,但源码摘录未显示 Secure 属性,因此部署到 HTTPS 反向代理时应结合实际部署方式验证传输保护。
Architecture
架构中的关键约束来自实际调用关系:wrapWebSocket 先把 WebSocket 事件转换为 ISocket,setupChannelServer 再用该 socket 创建 SocketProtocol 和 ChannelServer,最后通过 services.exposeOnChannelServer 暴露服务。每个连接会生成 server-ws-${randomUUID()} 形式的连接 ID,并按客户端模式选择 trusted-host-relay 或 terminal-client 角色。
监听选项与服务发现
HttpServerOptions
源码定义的选项如下:
| 选项 | 类型 | 默认/回退 | 作用 |
|---|---|---|---|
serverId | string | ZCODE_SERVER_ID、主机名、zcode-server | 服务发现中的稳定标识 |
name | string | ZCODE_SERVER_NAME;未设置则省略 | 服务显示名称 |
host | string | 当前摘录未显示解析逻辑 | 监听主机候选值 |
authRequired | boolean | 显式值;否则为是否存在 ZCODE_SERVER_TOKEN | 是否要求鉴权 |
authToken | string | 当前摘录未显示消费逻辑 | 访问令牌候选值 |
spaFallback | boolean | 当前摘录未显示 | 是否启用 SPA 回退 |
staticRoot | string | 当前摘录未显示 | 静态资源根目录 |
workspaces | ServerRemoteWorkspaceInfo[] | 当前工作目录 | 服务暴露的工作区列表 |
resolveServerWorkspaces 体现了一个重要的覆盖优先级:只要调用方传入 options.workspaces,函数就原样返回;否则使用 ZCODE_SERVER_WORKSPACE,再回退到 process.cwd()。每个默认工作区的 label 使用路径 basename,basename 为空时回退到完整路径。
服务信息生成
createServerInfo 将配置解析结果组合为远端发现信息。它固定发布 ZCODE_VERSION 与 SERVER_REMOTE_PROTOCOL_VERSION,并声明 desktopContinuous、websocketRpc 和 processResourceTelemetry 三项能力。authRequired 的默认值直接由 ZCODE_SERVER_TOKEN 是否存在决定,这意味着“配置了 token”本身会改变默认鉴权行为。
1function readTrimmedEnv(name: string): string | undefined {
2 const value = process.env[name]?.trim();
3 return value ? value : undefined;
4}
5
6function resolveServerId(options: HttpServerOptions): string {
7 return (
8 options.serverId?.trim() || readTrimmedEnv("ZCODE_SERVER_ID") || hostname() || "zcode-server"
9 );
10}
11
12function resolveServerWorkspaces(options: HttpServerOptions): ServerRemoteWorkspaceInfo[] {
13 if (options.workspaces) {
14 return options.workspaces;
15 }
16 const workspacePath = readTrimmedEnv("ZCODE_SERVER_WORKSPACE") || process.cwd();
17 return [
18 {
19 path: workspacePath,
20 label: basename(workspacePath) || workspacePath,
21 },
22 ];
23}Source: http.ts
1function createServerInfo(options: HttpServerOptions): ServerRemoteInfo {
2 return {
3 serverId: resolveServerId(options),
4 ...(options.name?.trim() || readTrimmedEnv("ZCODE_SERVER_NAME")
5 ? { name: options.name?.trim() || readTrimmedEnv("ZCODE_SERVER_NAME") }
6 : {}),
7 version: ZCODE_VERSION,
8 protocolVersion: SERVER_REMOTE_PROTOCOL_VERSION,
9 authRequired: options.authRequired ?? Boolean(readTrimmedEnv("ZCODE_SERVER_TOKEN")),
10 workspaces: resolveServerWorkspaces(options),
11 capabilities: {
12 desktopContinuous: true,
13 websocketRpc: true,
14 processResourceTelemetry: true,
15 },
16 };
17}Source: http.ts
令牌保护流程
受保护路径
源码定义的令牌 Cookie 名称为 zcode_lite_token。isTokenProtectedPath 将精确路径 /ws、以 /ws/ 开头的路径,以及以 /api/ 开头的路径纳入保护范围。静态资源路径本身不在已读到的保护判断中;因此不要把该实现描述成“所有 Web 请求都需要令牌”。
查询参数到 Cookie
hasValidLiteToken 的控制流分为两条路径:
- 从请求 URL 读取
token查询参数。 - 如果查询参数与服务端令牌完全相等,写入
Set-Cookie,并返回true。 - 如果没有匹配的查询参数,则解析
cookie请求头,读取zcode_lite_token并与令牌比较。 - Cookie 解析按分号切分,每项只取第一个等号之后的值;没有有效名称或没有请求头时返回空 Map。
该顺序使 URL 令牌可以完成一次性引导,而后续请求复用 Cookie。Cookie 使用 encodeURIComponent 编码令牌,并设置 Path=/; HttpOnly; SameSite=Lax。实现没有在该片段中做大小写归一化或模糊匹配,因此令牌比较是精确字符串比较。
1const zcodeLiteTokenCookieName = "zcode_lite_token";
2
3function parseCookieHeader(header: string | undefined): Map<string, string> {
4 const cookies = new Map<string, string>();
5 if (!header) {
6 return cookies;
7 }
8 for (const part of header.split(";")) {
9 const separator = part.indexOf("=");
10 if (separator <= 0) {
11 continue;
12 }
13 const name = part.slice(0, separator).trim();
14 const value = part.slice(separator + 1).trim();
15 if (name) {
16 cookies.set(name, value);
17 }
18 }
19 return cookies;
20}
21
22function hasValidLiteToken(c: Context, token: string): boolean {
23 const url = new URL(c.req.url);
24 if (url.searchParams.get("token") === token) {
25 c.header(
26 "Set-Cookie",
27 `${zcodeLiteTokenCookieName}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax`,
28 );
29 return true;
30 }
31 return parseCookieHeader(c.req.header("cookie")).get(zcodeLiteTokenCookieName) === token;
32}Source: http.ts
WebSocket 与 RPC 通道
wrapWebSocket 是传输适配器,不承载业务状态。它将 message 事件的 Buffer、ArrayBuffer 或 Buffer[] 归一化为 Buffer,再包装成 VSBuffer 并触发 onData。close 和 error 都会触发 onClose 与 onEnd,因此上层 RPC 可以将网络关闭和错误视作连接生命周期结束。
写入方向检查 WebSocket 的 readyState 是否为 OPEN,只有连接仍可写时才发送底层 buffer。drain() 直接返回已解决的 Promise,说明该适配器没有实现额外的发送背压队列;连接关闭与销毁都调用 ws.close()。
setupChannelServer 随后建立以下链路:
Source: http.ts
1function setupChannelServer(
2 ws: WebSocket,
3 services: ServiceCollection,
4 clientMode: "desktop-continuous" | "web-remote-replayable",
5) {
6 const socket = wrapWebSocket(ws);
7 const protocol = new SocketProtocol(socket);
8 const rawServer = new ChannelServer(protocol, "server");
9 // 用日志中间件包装,统一记录所有 RPC 调用
10 const server = new LoggingChannelServer(rawServer, log);
11 const agentService = services.getOptional(IZCodeAgentService);
12 const connectionScope = agentService
13 ? createZCodeAgentConnectionScope(agentService, {
14 connectionId: `server-ws-${randomUUID()}`,
15 clientMode,
16 role: clientMode === "desktop-continuous" ? "trusted-host-relay" : "terminal-client",
17 })
18 : undefined;
19 const overrides = new Map<string, unknown>();
20 if (connectionScope) {
21 overrides.set(IZCodeAgentService.channelName, connectionScope.service);
22 }
23 services.exposeOnChannelServer(server, overrides);
24 socket.onClose(() => {
25 void connectionScope?.dispose();
26 rawServer.dispose();
27 });
28}Source: http.ts
连接模式会影响连接身份:desktop-continuous 使用 trusted-host-relay,另一个已知模式 web-remote-replayable 使用 terminal-client。当存在 Agent 服务时,连接会创建独立 scope;关闭 socket 时异步释放 scope,并同步销毁原始 RPC server,避免连接级资源继续存活。
Core Flow
Source: http.ts
上图中“鉴权失败路径”的具体状态码和响应体没有在可读取源码中确认,因此只描述为边界结果,不虚构为某个特定 HTTP 状态码。完整的路由注册位于同一文件后续区域,但本页在源码读取预算达到上限后未继续读取该区域。
API 与配置参考
环境变量
| 环境变量 | 读取位置 | 行为 |
|---|---|---|
ZCODE_SERVER_ID | resolveServerId | 在未提供 options.serverId 时作为服务 ID |
ZCODE_SERVER_NAME | createServerInfo | 在未提供 options.name 时作为可选显示名称 |
ZCODE_SERVER_TOKEN | createServerInfo | 未显式提供 authRequired 时决定默认鉴权开关 |
ZCODE_SERVER_WORKSPACE | resolveServerWorkspaces | 未提供 options.workspaces 时决定默认工作区路径 |
所有环境变量都经过 trim();空字符串会被视为未设置。源码没有显示 authToken 如何与环境变量绑定,因此不能把 ZCODE_SERVER_TOKEN 进一步断言为令牌值本身在所有调用路径中的唯一来源。
createServerInfo(options: HttpServerOptions): ServerRemoteInfo
生成服务发现信息。它读取服务 ID、显示名称、版本、协议版本、鉴权标志和工作区,并固定发布三项能力标记。
- 参数:
options,HTTP 服务配置对象。 - 返回值:
ServerRemoteInfo。 - 异常:已读取实现中未显式抛出异常。
resolveServerWorkspaces(options: HttpServerOptions): ServerRemoteWorkspaceInfo[]
解析工作区配置。显式 workspaces 即使为空数组也会直接返回;只有属性为 undefined 时才使用环境变量或当前工作目录。
hasValidLiteToken(c: Context, token: string): boolean
检查 URL 查询参数或 Cookie 中的 lite token。查询参数匹配时会通过响应上下文追加 Set-Cookie;Cookie 匹配时不会重新写 Cookie。
setupChannelServer(ws, services, clientMode)
建立 WebSocket RPC 服务端并暴露 ServiceCollection 中的频道。clientMode 只接受源码中声明的 desktop-continuous 或 web-remote-replayable;如果 Agent 服务可用,会创建连接 scope 并在连接关闭时释放。
Failure Modes, Edge Cases & Concurrency
- 空配置值:环境变量经过 trim,空白值不会成为服务 ID、名称或工作区路径。
- 工作区显式空数组:
options.workspaces使用存在性判断,因此空数组不会触发当前目录回退。 - Cookie 格式异常:没有等号、等号位于首字符,或名称为空的片段会被忽略;值本身可以包含后续等号,因为解析只使用第一个等号。
- WebSocket 错误:
error与close都触发关闭事件和结束事件;上层应避免把两次事件当成两个独立连接。 - 连接关闭清理:
socket.onClose会异步调用connectionScope.dispose(),并调用rawServer.dispose()。源码未展示 dispose 失败时的额外重试或错误上报策略。 - 发送背压:
drain()不等待底层缓冲区,而是立即解决 Promise;高吞吐场景的流控不由此适配器实现。 - 连接标识:连接 ID 使用
randomUUID()生成,避免多个 WebSocket 连接共享 Agent scope 标识。 - 鉴权边界:已确认的受保护路径只有
/ws、/ws/...和/api/...。未读取到完整路由注册逻辑,因此其他路径的鉴权行为应以实际后续代码和运行时配置为准。
Performance / Operational Notes
HTTP 文件顶部明确说明 HTTP、WebSocket 和静态资源路由集中注册,以保持相同的鉴权顺序。RPC 调用统一经过 LoggingChannelServer,便于诊断;同时,wrapWebSocket 对每条消息做 Buffer/VSBuffer 包装,连接关闭时释放 RPC 服务端和 Agent connection scope。
服务信息中声明 processResourceTelemetry,但本页已读取源码只确认能力标记,没有确认遥测数据的采集端点或采样策略。不要仅根据能力标记推断完整监控实现。
Extension Points
- 使用
HttpServerOptions.workspaces提供多工作区时,应传入完整的ServerRemoteWorkspaceInfo[],因为该字段会直接覆盖环境变量和当前目录推导。 - 需要增加服务发现字段时,应围绕
createServerInfo扩展ServerRemoteInfo的真实共享类型,而不是在 HTTP 路由中拼接未声明的 JSON。 - 需要增加连接级服务行为时,应通过
createZCodeAgentConnectionScope和overrides注入连接范围服务;当前实现已经为IZCodeAgentService保留了按连接覆盖的路径。 - 修改 WebSocket 生命周期时,必须同时保持
onClose、onEnd、rawServer.dispose()和 connection scope 清理之间的一致性。