Repository Wiki
zai-org/ZCode

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 入口承担两类边界职责:

  1. 发现与配置边界:从显式 HttpServerOptions 或环境变量解析服务身份和工作区,生成 ServerRemoteInfo。显式 workspaces 优先于环境变量和当前工作目录;服务 ID 则按显式值、ZCODE_SERVER_ID、主机名、zcode-server 的顺序回退。
  2. 传输与权限边界:将 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

Loading diagram...

架构中的关键约束来自实际调用关系:wrapWebSocket 先把 WebSocket 事件转换为 ISocket,setupChannelServer 再用该 socket 创建 SocketProtocol 和 ChannelServer,最后通过 services.exposeOnChannelServer 暴露服务。每个连接会生成 server-ws-${randomUUID()} 形式的连接 ID,并按客户端模式选择 trusted-host-relay 或 terminal-client 角色。

监听选项与服务发现

HttpServerOptions

源码定义的选项如下:

选项类型默认/回退作用
serverIdstringZCODE_SERVER_ID、主机名、zcode-server服务发现中的稳定标识
namestringZCODE_SERVER_NAME;未设置则省略服务显示名称
hoststring当前摘录未显示解析逻辑监听主机候选值
authRequiredboolean显式值;否则为是否存在 ZCODE_SERVER_TOKEN是否要求鉴权
authTokenstring当前摘录未显示消费逻辑访问令牌候选值
spaFallbackboolean当前摘录未显示是否启用 SPA 回退
staticRootstring当前摘录未显示静态资源根目录
workspacesServerRemoteWorkspaceInfo[]当前工作目录服务暴露的工作区列表

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”本身会改变默认鉴权行为。

typescript
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

typescript
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 请求都需要令牌”。

hasValidLiteToken 的控制流分为两条路径:

  1. 从请求 URL 读取 token 查询参数。
  2. 如果查询参数与服务端令牌完全相等,写入 Set-Cookie,并返回 true。
  3. 如果没有匹配的查询参数,则解析 cookie 请求头,读取 zcode_lite_token 并与令牌比较。
  4. Cookie 解析按分号切分,每项只取第一个等号之后的值;没有有效名称或没有请求头时返回空 Map。

该顺序使 URL 令牌可以完成一次性引导,而后续请求复用 Cookie。Cookie 使用 encodeURIComponent 编码令牌,并设置 Path=/; HttpOnly; SameSite=Lax。实现没有在该片段中做大小写归一化或模糊匹配,因此令牌比较是精确字符串比较。

typescript
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 随后建立以下链路:

Loading diagram...

Source: http.ts

typescript
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

Loading diagram...

Source: http.ts

上图中“鉴权失败路径”的具体状态码和响应体没有在可读取源码中确认,因此只描述为边界结果,不虚构为某个特定 HTTP 状态码。完整的路由注册位于同一文件后续区域,但本页在源码读取预算达到上限后未继续读取该区域。

API 与配置参考

环境变量

环境变量读取位置行为
ZCODE_SERVER_IDresolveServerId在未提供 options.serverId 时作为服务 ID
ZCODE_SERVER_NAMEcreateServerInfo在未提供 options.name 时作为可选显示名称
ZCODE_SERVER_TOKENcreateServerInfo未显式提供 authRequired 时决定默认鉴权开关
ZCODE_SERVER_WORKSPACEresolveServerWorkspaces未提供 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 清理之间的一致性。

Sources

(2 files)
(root)
packages/server/src