Huly 服务端客户端库@hcengineering/server-client深入解析:从版本演进到源码实现
【免费下载链接】platformHuly — All-in-One Project Management Platform (alternative to Linear, Jira, Slack, Notion, Motion)项目地址: https://gitcode.com/GitHub_Trending/platform80/platform
@hcengineering/server-client是 Huly 全栈平台(platform 为主线,结合其源码,完整梳理这个包从 0.7.0 首版发布到 0.7.17 的演进脉络,并逐一解析其四大核心能力——服务端 WebSocket 客户端工厂、transactor 端点发现、认证 Token 提取与 Blob 流式读写,让读者既能看懂版本历史,也能直接将其 API 用于自己的服务端集成。
一、包定位:服务端进程与平台之间的通信层
在 Huly 的架构中,浏览器端使用@hcengineering/client与 transactor 通信,而服务端进程(如 collaborator、workspace-service、tool、account-service 等)则需要一套针对 Node.js 环境优化的客户端设施。@hcengineering/server-client正是为此而生。
从 package.json 可以看到它的依赖关系,清晰地揭示了它的职责边界:
"dependencies": { "@hcengineering/platform": "workspace:^0.7.20", "@hcengineering/core": "workspace:^0.7.26", "@hcengineering/client-resources": "workspace:^0.7.19", "@hcengineering/client": "workspace:^0.7.19", "@hcengineering/account-client": "workspace:^0.7.25", "@hcengineering/server-core": "workspace:^0.7.19", "@hcengineering/server-token": "workspace:^0.7.18", "ws": "^8.18.2" }可以看到它复用了平台层的client与client-resources建立基础连接,通过account-client与账号服务交互,通过server-token完成 JWT 解析,并直接依赖ws包——这正是它与浏览器端客户端的本质区别:浏览器客户端使用浏览器原生 WebSocket,而服务端客户端使用 Node.js 的ws库。
包的入口 src/index.ts 导出四个模块:
export * from './account' export * from './blob' export * from './client' export * from './token'下面逐一深入。
二、版本演进:从 0.7.0 首版到 0.7.17
CHANGELOG.md 记录了完整的发布历史。该日志由工具自动生成(This log was last generated on Wed, 26 Nov 2025 16:04:43 GMT and should not be manually modified.),因此每个条目的描述非常精简,但结合 CHANGELOG.json 与 package.json 中的当前版本0.7.18,可以还原出完整的演进脉络:
| 版本 | 发布时间 | 变更说明 | 解读 |
|---|---|---|---|
| 0.7.0 | 2025-10-09 | Initial release | 包首次发布 |
| 0.7.1 | 2025-10-10 | New deps | 依赖更新,随 platform-rig 工具链演进 |
| 0.7.2 | 2025-10-11 | Use latest platform-rig | 升级到最新的@hcengineering/platform-rig构建工具链 |
| 0.7.3 | 2025-10-13 | update deps | 常规依赖更新 |
| 0.7.4 | 2025-10-14 | deps | 常规依赖更新 |
| 0.7.5 | 2025-10-14 | Version update only | 仅版本号同步 |
| 0.7.6 | 2025-10-15 | Version update only | 仅版本号同步 |
| 0.7.7 | 2025-10-17 | Update deps | 依赖更新 |
| 0.7.8 | 2025-10-21 | bump core | 跟随@hcengineering/core升级 |
| 0.7.9 | 2025-10-27 | update deps | 依赖更新 |
| 0.7.15 | 2025-10-27 | Use updated deps | 依赖更新 |
| 0.7.16 | 2025-11-06 | bump core | 再次跟随@hcengineering/core升级 |
| 0.7.17 | 2025-11-26 | Bump | 常规版本推进 |
从这份日志可以读出三个规律:第一,该包的绝大多数变更都是"跟随核心包升级",尤其是bump core(跟随@hcengineering/core)与Use latest platform-rig(跟随构建工具链),说明它作为底层基础设施,稳定性优先;第二,10 月 14 日一天内连续发布了 0.7.4 / 0.7.5 / 0.7.6 三个版本,其中两个标注Version update only,说明存在密集的依赖同步流程;第三,从 0.7.0 到 0.7.17 均为Patches级别的补丁版本,没有破坏性变更,进一步印证了其 API 的稳定性。当前仓库中 package.json 的版本已是0.7.18,略微领先于 CHANGELOG 记录的最后一条。
三、核心能力一:createClient—— 服务端 WebSocket 客户端工厂
src/client.ts 是包的"心脏"。它导出一个异步工厂函数:
export async function createClient ( transactorUrl: string, token: string, model?: Tx[], connectTimeout: number = 0 ): Promise<Client>它的工作流程分为四步:
1. 用ws替换浏览器 WebSocket 工厂。这是服务端与浏览器端最关键的分水岭:
const WebSocket = require('ws') setMetadata(client.metadata.ClientSocketFactory, (url) => { const socket = new WebSocket(url, { headers: { 'User-Agent': getMetadata(plugin.metadata.UserAgent) ?? 'Anticrm Client' } }) return socket })服务端进程通过ws库建立真实的 WebSocket 连接,并可以自定义User-Agent请求头——这个值通过 src/plugin.ts 中声明的插件元数据plugin.metadata.UserAgent注入。
2. 强制启用二进制协议与压缩。服务端客户端默认使用二进制协议和协议压缩,这与浏览器端"尽量兼容"的策略不同,服务端场景更看重传输效率:
setMetadata(client.metadata.UseBinaryProtocol, true) setMetadata(client.metadata.UseProtocolCompression, true)3. 设置连接超时。connectTimeout参数(毫秒,默认0表示不超时)会被写入client.metadata.ConnectionTimeout,注释明确指出其语义:"If connectTimeout is set, connect will try to connect only specified amount of time, and will return failure if failed."即一旦超时立即失败,而不是无限等待。
4. 可选注入预置模型。当传入model?: Tx[]时,代码会基于 sha1 对事务列表做链式哈希(每个事务的哈希由前一个哈希拼接其 JSON 序列化结果后计算得出),并覆写平台的持久化存储,使客户端直接使用传入的模型而无需从服务端加载:
if (model !== undefined) { let prev = '' const hashes = model.map((it) => { const h = crypto.createHash('sha1') h.update(prev) h.update(JSON.stringify(it)) prev = h.digest('hex') return prev }) setMetadata(client.metadata.OverridePersistenceStore, { load: async () => ({ hash: hashes[hashes.length - 1], transactions: model, full: true }), store: async (model: LoadModelResponse) => {} }) }最后通过getResource(client.function.GetClient)获取真正的客户端工厂实现(来自client-resources),并以token与transactorUrl完成连接。这意味着调用方只需要关心"连到哪里、用什么凭证",而把协议细节完全交给平台层。
四、核心能力二:getTransactorEndpoint—— 端点发现与重试策略
服务端进程通常只知道账号服务(account service)的地址,而不知道具体应该连接哪个 transactor 端点。src/account.ts 中的getTransactorEndpoint正是解决这个问题的:
export async function getTransactorEndpoint ( token: string, kind: 'internal' | 'external' | 'byregion' = 'byregion', timeout: number = -1 ): Promise<string>它的工作方式是:通过账号客户端调用selectWorkspace('', kind, externalRegions)查询当前 workspace 对应的端点。三个参数的含义:
kind:端点类型,'internal'(内部端点)、'external'(外部端点)或'byregion'(按区域选择,默认值);timeout:总超时毫秒数,默认-1表示不超时;externalRegions:来自环境变量process.env.EXTERNAL_REGIONS?.split(';'),即用分号分隔的外部区域列表。
值得注意的细节是连接错误重试逻辑。代码定义了connectionErrorCodes = ['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND'],在循环中:
- 若超时(
timeout > 0 && st + timeout < Date.now()),直接抛出原错误; - 若错误原因码属于连接类错误(如
ECONNREFUSED),等待 1 秒后重试; - 其他错误直接抛出。
这种"只对连接类错误无限重试、其他错误快速失败"的策略,恰好契合服务端进程启动阶段账号服务尚未就绪的场景。
同文件还提供了三个可复用的重试工具函数:
withRetry(f, shouldFail, intervalMs = 1000):通用的重试包装器,只要shouldFail(err, attempt)返回false就持续重试;withRetryConnUntilTimeout(f, timeoutMs = 5000):在指定时间窗内(默认 5 秒)对连接类错误重试;withRetryConnUntilSuccess(f):对连接类错误无限重试,直到成功;对其他错误打印console.error后抛出。
此外,getAccountClient(token?, retryTimeoutMs?)是对@hcengineering/account-client中getClient的薄封装,账号服务地址来自plugin.metadata.Endpoint元数据。
五、核心能力三:readToken/extractToken—— 认证 Token 提取
服务端进程(尤其是 HTTP 服务)需要从请求头中解析出平台 Token。src/token.ts 提供了两个函数:
readToken(headers: IncomingHttpHeaders): string | undefined:返回原始 Token 字符串;extractToken(headers): Token | undefined:返回解码后的Token对象。
Token 的提取优先级为:Authorization头优先,Cookie兜底。具体来说:
Authorization头:按Bearer <token>格式解析,取空格后的第二部分:const encodedToken = authorization.split(' ')[1]Cookie头:遍历所有 cookie,寻找名称包含token的项(大小写不敏感),取出其值并调用decodeToken(来自@hcengineering/server-token)验证:const cookies = cookie.split(';') for (const cookie of cookies) { if (cookie.toLocaleLowerCase().includes('token')) { const encodedToken = cookie.split('=')[1] const token = decodeToken(encodedToken) if (token.workspace != null) { return encodedToken // 带 workspace 的 token 立即返回 } authToken = encodedToken // 否则暂存 } }注意这里的细节:如果 cookie 中的 token 解码后带有 workspace 信息,立即返回;否则暂存为后备。这一设计在 Huly 的多工作区模型下很有意义——服务端往往需要的是与具体 workspace 绑定的请求上下文。
extractToken则在readToken的基础上多走一步decodeToken,把字符串转为结构化对象(包含账号与工作区信息),任何一步失败都返回undefined,保证调用方的健壮性。
六、核心能力四:BlobClient—— 大对象流式读写
src/blob.ts 中的BlobClient封装了对平台存储适配器(StorageAdapter,来自@hcengineering/server-core)的大对象(Blob)访问:
export class BlobClient { constructor ( readonly storageAdapter: StorageAdapter, readonly workspace: WorkspaceIds ) { ... } }它对外暴露三个方法:
checkFile(ctx, name): Promise<boolean>:通过storageAdapter.stat判断对象是否存在;upload(ctx, name, size, contentType, buffer):将内存中的Buffer直接写入存储;writeTo(ctx, name, size, writable):将存储对象以流式方式写出,这是最有技术含量的一个。
writeTo的实现值得展开。它按50MB(chunkSize = 50 * 1024 * 1024)为单位,通过storageAdapter.partial分段读取并写入目标流,内置了多层容错:
- 单块重试:每个 50MB 块最多尝试 5 次,失败后按
100 * (i + 1)毫秒退避; - 空块保护:连续收到 3 次(
maxEmptyChunkRetries = 3)空块即中止并抛出错误,防止无限循环,同时每次空块重试间隔递增(100 * emptyChunkRetries毫秒); - 键不存在容错:若底层存储返回
NoSuchKey/NotFound等错误码,视为"对象不存在",直接结束写入而非报错; - 写入错误传播:
writable.write的回调若收到错误会reject,从而中断整个流程。
整个设计体现了一个面向大文件(视频、文档等)传输的服务端客户端应有的品质:分块、限流、退避重试、防死循环。
七、仓库中的实际使用
在仓库中,该包的 API 被多个服务端组件消费,印证了其作为基础设施的定位。例如在 server/account-service/src/index.ts、server/collaborator/src/platform.ts、server/tool/src/utils.ts、server/workspace-service/src/service.ts 等文件中,都通过getTransactorEndpoint或extractToken完成端点发现与请求认证。这些服务端进程的典型接入模式是:
- 读取配置得到账号服务地址,设置
plugin.metadata.Endpoint; - 用某个账号 Token 调用
getTransactorEndpoint(token, kind, timeout)拿到 transactor 地址; - 调用
createClient(transactorUrl, token, model, connectTimeout)建立连接; - 在收到 HTTP 请求时,用
extractToken(headers)解析请求方身份。
八、构建与测试
作为一个标准 Rush monorepo 包,它的 package.json 中提供了完善的开发脚本:
"scripts": { "build": "compile", "build:watch": "compile", "format": "format src", "test": "jest --passWithNoTests --silent --forceExit", "_phase:build": "compile transpile src", "_phase:test": "jest --passWithNoTests --silent --forceExit", "_phase:format": "format src", "_phase:validate": "compile validate" }构建与校验通过@hcengineering/platform-rig(compile/validate指令)完成,测试使用 Jest(当前仓库中无独立测试文件,因此--passWithNoTests保证空跑通过)。包发布时只包含lib/**/*、types/**/*与src/**/*,并显式排除了所有测试文件(见files字段),符合 npm 包发布的最佳实践。由于该包不含浏览器端代码,发布时无需 Svelte 相关处理,但保留了"svelte": "src/index.ts"字段以便工具链统一识别入口。
结语
回顾整个 CHANGELOG.md,@hcengineering/server-client用十余个"平凡"的补丁版本维系了服务端客户端的稳定,而这背后是四个精悍模块的支撑:client.ts的createClient以ws工厂切入平台协议栈并默认启用二进制压缩,account.ts的getTransactorEndpoint以"连接错误无限重试"的策略完成端点发现,token.ts以"Authorization 优先、Cookie 兜底"的规则解析请求身份,blob.ts的BlobClient以 50MB 分块加多层容错实现大对象流式传输。对于任何希望在 Node.js 进程中与 Huly 平台集成的开发者,理解这四个模块就等于掌握了服务端接入的全部关键路径。
【免费下载链接】platformHuly — All-in-One Project Management Platform (alternative to Linear, Jira, Slack, Notion, Motion)项目地址: https://gitcode.com/GitHub_Trending/platform80/platform
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考