Etherpad Admin 前端 API 类型生成:`@etherpad/openapi-codegen` 构建期 TypeScript 锁定方案解析
2026/9/21 1:48:34 网站建设 项目流程
  • 后端
  • 协同办公
  • WebSocket
  • 前端
  • 富文本

【免费下载链接】etherpad

Etherpad: A modern really-real-time collaborative document editor.

项目地址:https://gitcode.com/gh_mirrors/et/etherpad
点击查看免费下载

导读

Etherpad 的管理后台(admin/,基于 React + Vite)通过openapi-typescript从服务端 OpenAPI 规范生成类型安全的 API 客户端。但openapi-typescript依赖 TypeScript编译器 API生成代码,而 TypeScript 7(原生移植版)已不暴露该 API,导致代码生成直接崩溃。本文基于仓库中的 admin/tools/openapi-codegen/README.md,深入解析 Etherpad 如何通过一个私有的构建期包@etherpad/openapi-codegenopenapi-typescript所用的 TypeScript 锁定在 6.x,从而绕开该兼容性问题,并完整梳理从 OpenAPI 规范导出、合并、生成schema.d.ts到前端类型客户端消费的整条调用链。

一、背景:为什么 Admin 前端需要“从 OpenAPI 生成类型”

Etherpad 仓库的admin/目录是一个独立的 Vite + React 前端工程(见 admin/package.json),它依赖openapi-fetch(类型化 HTTP 客户端)与openapi-react-query(基于 TanStack Query 的 hooks),二者都要求一个由 OpenAPI 规范生成的 TypeScript 类型文件作为泛型来源。

从源码结构看(对应 issue #7638 的类型安全 API 工作),类型生成的完整链路是:

  1. 服务端在运行时构建 OpenAPI 文档:公共 API 由 src/node/hooks/express/openapi.ts 的generateDefinitionForVersion(version, style)生成,管理端 API 由 src/node/hooks/express/openapi-admin.ts 的generateAdminDefinition()生成;
  2. 构建脚本把两份文档合并成一份,交给openapi-typescript输出admin/src/api/schema.d.ts
  3. 前端代码import type { paths } from './schema',从而获得全程类型检查的 API 调用体验。

其中第 2 步正是@etherpad/openapi-codegen存在的核心场景。

二、问题根源:openapi-typescript依赖 TypeScript 编译器 API

openapi-typescript生成输出的方式不是简单地拼接字符串,而是调用 TypeScript 的编译器 APIts.factoryts.SyntaxKind、printer)来程序化地构建 AST 并打印为.d.ts文件。

问题出在 TypeScript 7:它是 TypeScript 的原生(native)移植版本,其主入口只导出./lib/version.cjs编译器 API 完全不在其中。因此当openapi-typescript运行在 TS 7 环境下时,拿不到ts.factory,代码生成会直接崩溃,报错信息为:

TypeError: Cannot read properties of undefined (reading 'createKeywordTypeNode')

这本质上是一个上游生态迁移期的不兼容:openapi-typescript声明的 peer 依赖范围是typescript: ^5.x,而截至当前仓库版本,openapi-typescript的最新版为 7.13.0,尚没有可升级到“支持原生编译器”的版本,因此短期内只能通过锁定 TypeScript 版本来规避。

三、为什么根级 pin 无效:pnpm peer 依赖解析机制

一个自然的想法是在工作区根或admin里用overrides强制固定 TypeScript 版本。但仓库 README 明确指出这条路走不通,原因在于 pnpm 的peer 依赖解析机制

  • openapi-typescripttypescript声明为peer 依赖(而非普通 devDependency);
  • pnpm 会从实际依赖openapi-typescript的那个包的环境里解析 peer,而不是从工作区根解析;
  • admin同时依赖openapi-typescript并声明typescript: ^7.0.2的情况下,peer 始终被解析到 7.x;
  • 无论是根pnpm.overrides还是packageExtensions,都无法覆盖这个 peer 解析结果(README 原文:neitheroverridesnorpackageExtensionsoverrides that)。

也就是说,想让openapi-typescript拿到一个带编译器 API 的 TypeScript,就必须让“依赖它的包”自身只携带一个 6.x 的 TypeScript。

四、解决方案:独立的构建期私有包

Etherpad 的解法是把生成器隔离进一个私有、仅构建期使用的独立包@etherpad/openapi-codegen,其完整package.json如下(admin/tools/openapi-codegen/package.json):

{ "name": "@etherpad/openapi-codegen", "version": "0.0.0", "private": true, "description": "Build-time wrapper that runs openapi-typescript against a TypeScript it can actually use. See README.md.", "devDependencies": { "openapi-typescript": "^7.13.0", "typescript": "^6.0.3" } }

关键点在于:

  • 该包是openapi-typescript的唯一“宿主”:由于openapi-typescript在此包内声明为 devDependency,pnpm 就会从这个包所在的依赖环境里解析它的 peertypescript
  • 该包只声明了typescript: ^6.0.3,因此 peer 必然解析到带完整编译器 API 的 6.x,而非admin里的^7.0.2(见 admin/package.json);
  • private: true表明它绝不发布,version: 0.0.0是典型的“内部工具占位”写法。

要让 pnpm 工作区识别这个包,还需要在 pnpm-workspace.yaml 中注册它,该文件明确写有对应注释:

packages: - src - admin - bin - doc - ui # Build-time only: pins the TypeScript that openapi-typescript runs # against, which cannot be TypeScript 7. See its README. - admin/tools/openapi-codegen

五、构建期调用链:从规范导出到schema.d.ts

@etherpad/openapi-codegen不是被直接 import 的库,而是通过admin的构建脚本以子进程方式调用。入口脚本为 admin/scripts/gen-api.mjs,由admin/package.json"gen:api": "node scripts/gen-api.mjs"触发,并被devbuildtest等脚本前置调用。

5.1 第一步:导出并合并 OpenAPI 文档

gen-api.mjs 先通过pnpm exec tsx scripts/dump-spec.ts <tmp-spec-path>运行 admin/scripts/dump-spec.ts。dump-spec.ts会:

  1. 从源码动态 import 三个模块:
    • src/node/handler/APIHandler.ts(提供latestApiVersion);
    • src/node/hooks/express/openapi.ts(提供generateDefinitionForVersionAPIPathStyle);
    • src/node/hooks/express/openapi-admin.ts(提供generateAdminDefinition);
  2. FLAT 风格生成公共 API 规范:openapi.generateDefinitionForVersion(apiHandler.latestApiVersion, openapi.APIPathStyle.FLAT)。从 src/node/hooks/express/openapi.ts 可见APIPathStyle定义了FLAT: 'api'(如/api/createGroup)与REST: 'rest'(如/rest/group/create)两种风格,info 中的version取自apiHandler.latestApiVersion
  3. 生成管理端规范:openapiAdmin.generateAdminDefinition(),其info.version取自getEpVersion()openapi字段固定为3.0.2(见 src/node/hooks/express/openapi-admin.ts);
  4. 用 admin/scripts/merge-openapi.mjs 的mergeOpenAPI(publicSpec, adminSpec)深度合并:pathscomponents.{schemas,parameters,responses,securitySchemes}按键联合(碰撞即抛错),根级info/servers/security以公共规范为准,而 admin 路径上的 per-operationsecurity原样保留;
  5. 将合并后的 JSON 写入临时目录(之所以用文件参数而非 stdout,是因为 importopenapi*.ts会触发 Settings 初始化、log4js 向 stdout 写日志,会污染 JSON 输出)。

5.2 第二步:在锁定包内运行openapi-typescript

合并后的 spec 由第二个子进程消费,这正是@etherpad/openapi-codegen发挥作用的地方:

const gen = spawnSync( 'pnpm', ['--filter', '@etherpad/openapi-codegen', 'exec', 'openapi-typescript', specPath, '-o', outFile], spawnOpts, );

即通过pnpm --filter @etherpad/openapi-codegen exec openapi-typescript让生成器在这个包的依赖环境里运行,从而使用被锁定的 TS 6.x。gen-api.mjs中还做了 Windows 兼容处理:spawnSync查找pnpm.cmd需要 shell,因此仅在process.platform === 'win32'时启用shell: true,并注明所有参数均为固定值、无注入风险。

5.3 第三步:写回生成产物

生成完成后,脚本会:

  • admin/src/api/schema.d.ts加上“GENERATED — do not edit. Runpnpm --filter admin gen:apito regenerate.”的文件头;
  • 解析 spec 的info.version,额外生成 admin/src/api/version.ts(构建期产物):
    export const LATEST_API_VERSION = "..."; export const API_BASE_URL = `/api/${LATEST_API_VERSION}`;

    注释说明:生成的 paths 是不带前缀的(如/createGroup),而后端把 FLAT 风格规范挂载在/api/<version>/下,因此需要该常量来拼出正确的baseUrl

  • 最后在finally中清理临时目录。

需要强调的是:schema.d.tsversion.ts都是构建期生成的纯文本文件(不在仓库源码目录中提交),其内容对tsc 7而言只是普通的.d.ts,因此生成器虽然必须跑在 TS 6.x 上,生成的产物却能毫无障碍地被admin的 TS 7 编译链消费——README 中"that output is plain text whichtsc7 then consumes normally"指的就是这一点。这也解释了为何锁包方案不会污染前端工程本身的 TS 版本。

六、产物如何被前端消费

生成的schema.d.ts被 admin/src/api/client.ts 以import type { paths } from './schema'引入。该文件利用路径前缀把合并后的paths拆成两个面:

  • 公共版本化 API:/api/<version>/下的路径(如/createGroup);
  • 管理端 API:根路径下的/admin...路径(如/admin-auth/)。
type AdminPath = Extract<keyof paths, `/admin${string}`>; type PublicPath = Exclude<keyof paths, AdminPath>; type PublicPaths = Pick<paths, PublicPath>; type AdminPaths = Pick<paths, AdminPath>; export const fetchClient = createClient<PublicPaths>({ baseUrl: API_BASE_URL }); export const adminFetchClient = createClient<AdminPaths>({ baseUrl: '/' }); export const $api = createQueryHooks(fetchClient); export const $adminApi = createQueryHooks(adminFetchClient);

这样 TypeScript 会在编译期拒绝“在公共客户端上调用 admin 路径”或反之,避免出现共享客户端在运行时把baseUrl打到错误表面的问题。配套的冒烟测试 admin/src/api/tests/client.test.ts 会校验四个导出(fetchClientadminFetchClient$api$adminApi)以及GETuseQuery等关键方法存在,用于在工具链接线回归(如 peer 依赖缺失、生成器输出没有paths导出)时第一时间暴露问题。TanStack Query 的 provider 则在 admin/src/api/QueryProvider.tsx 中配置(staleTime: 30_000,devtools 仅在开发构建懒加载)。另外,合并规则的单元测试位于 admin/scripts/tests/merge-openapi.test.mjs。

七、这个锁包方案的取舍与清理路径

Etherpad 选择“独立私有包”而非“根级 pin”,本质上是顺着 pnpm 的 peer 解析规则顺势而为:与其对抗解析机制,不如创建一个peer 依赖的唯一宿主,让解析结果自然落入期望版本。代价是多了一个微包,换来的是:

  • admin可以继续自由使用 TS 7(前端构建、类型检查不受影响);
  • 生成器始终运行在具备编译器 API 的 TS 6.x 上;
  • 该包仅存在于构建期,不会随任何产物发布(README:Nothing here ships)。

README 最后给出了明确的清理条件与步骤:一旦openapi-typescript支持原生编译器(TS 7),就删除@etherpad/openapi-codegen,把openapi-typescript移回admin的 devDependencies,并让 admin/scripts/gen-api.mjs 直接调用它。这是一个“临时补丁方案 + 显式退役条件”的教科书式写法——工具本身知道自己的生命周期终点。

八、总结

@etherpad/openapi-codegen是 Etherpad 构建体系里一个精巧的“小手术”:

  1. 问题openapi-typescript依赖 TS 编译器 API,而 TS 7(原生移植)不再暴露该 API,报错createKeywordTypeNode
  2. 原因typescript作为 peer 依赖,由依赖它的包决定版本,根级overrides/packageExtensions均无法干预;
  3. 方案:用private构建期包独占openapi-typescript,并把typescript锁在^6.0.3,配合 pnpm-workspace.yaml 注册与pnpm --filter子进程调用;
  4. 边界:锁定的只是“生成器运行环境”,生成的schema.d.ts纯文本产物由tsc 7正常消费,前端 TS 版本不受影响;
  5. 未来:待openapi-typescript支持原生编译器后,按 README 指定的清理路径删除该包。

对于任何在 pnpm monorepo 中遭遇“peer 依赖版本与编译器 API 冲突”的团队,这个案例提供了一个可复用的思路:不要对抗 peer 解析,而是为 peer 构造一个受控的宿主包

  • 后端
  • 协同办公
  • WebSocket
  • 前端
  • 富文本

【免费下载链接】etherpad

Etherpad: A modern really-real-time collaborative document editor.

项目地址:https://gitcode.com/gh_mirrors/et/etherpad
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询