From 9cdcc616162393a12d8eae0f8bdc33e3e0407f61 Mon Sep 17 00:00:00 2001 From: Yourtion Guo Date: Tue, 27 Oct 2026 13:06:13 +0000 Subject: [PATCH 1/3] feat(h3): add H3 v2 adapter and prepare 3.4.0 --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 5 +- AGENTS.md | 5 +- CONTRIBUTING.md | 7 +- MIGRATION.md | 9 + README.md | 7 +- RELEASING.md | 17 +- guides/adapters.md | 51 ++++- package.json | 21 +- packages/erest-express/package.json | 2 +- packages/erest-gen/package.json | 2 +- packages/erest-h3/package.json | 41 ++++ packages/erest-h3/src/index.ts | 149 +++++++++++++ packages/erest-h3/tsconfig.json | 14 ++ packages/erest-h3/tsconfig.test.json | 9 + packages/erest-koa/package.json | 2 +- packages/erest-leizmweb/package.json | 2 +- pnpm-lock.yaml | 46 ++++ pnpm-workspace.yaml | 3 +- scripts/npm-release-smoke.mjs | 16 +- scripts/npm-release.mjs | 2 +- scripts/npm-release.test.mjs | 10 +- src/lib/adapters/types.ts | 12 +- src/lib/index.ts | 4 +- src/test/adapters.ts | 2 + src/test/test-contract-consistency.ts | 11 +- src/test/test-integration-h3.ts | 303 ++++++++++++++++++++++++++ 27 files changed, 703 insertions(+), 51 deletions(-) create mode 100644 packages/erest-h3/package.json create mode 100644 packages/erest-h3/src/index.ts create mode 100644 packages/erest-h3/tsconfig.json create mode 100644 packages/erest-h3/tsconfig.test.json create mode 100644 src/test/test-integration-h3.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cae66db..455b823 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,7 +83,7 @@ jobs: with: name: npm-release path: ${{ runner.temp }}/npm-release/ - # Each of the five npm packages must trust yourtion/node-erest/release.yml. + # Each of the six npm packages must trust yourtion/node-erest/release.yml. # No GitHub environment is used; leave that npm field blank. - name: Publish validated tarballs, then complete GitHub Release env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6e50eb5..2460785 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,10 +42,13 @@ jobs: run: pnpm run build - name: Build adapter packages - # 子包 (@erest/express|koa|leizmweb|gen) 的 dist/ 已 gitignore, + # 子包 (@erest/express|koa|leizmweb|h3|gen) 的 dist/ 已 gitignore, # examples 测试通过 workspace 链接 import 它们,必须先构建才能解析入口。 run: pnpm run build:packages + - name: Check H3 adapter and handler types + run: pnpm run test:h3-types + - name: Run tests with coverage run: pnpm run test:cov diff --git a/AGENTS.md b/AGENTS.md index e03b209..8427563 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ packages/ # v3.0:框架适配器实现独立子包(npm scope @er ├── erest-express/ # @erest/express — ExpressAdapter(Express 4/5) ├── erest-koa/ # @erest/koa — KoaAdapter(Koa 3) ├── erest-leizmweb/ # @erest/leizmweb — LeizmWebAdapter(@leizm/web 2) +├── erest-h3/ # @erest/h3 — H3Adapter(H3 2) └── erest-gen/ # @erest/gen — codegen CLI(命令名 erest-gen) ``` @@ -42,7 +43,7 @@ packages/ # v3.0:框架适配器实现独立子包(npm scope @er |-----------|--------| | 参数校验逻辑 | `params.ts` 的 `compileValidate`(bind 阶段预编译) | | 加新框架适配 | 新建子包 `packages/erest-/src/index.ts`,实现 `FrameworkAdapter` 接口(见 `adapters/types.ts`) | -| 改某框架适配实现 | `packages/erest-{express,koa,leizmweb}/src/index.ts`(`makeParamsChecker`/`bindRoute`/`createGroupRouter`/`attachGroupRouter`) | +| 改某框架适配实现 | `packages/erest-{express,koa,leizmweb,h3}/src/index.ts`(`makeParamsChecker`/`bindRoute`/`createGroupRouter`/`attachGroupRouter`) | | 改文档生成 | `plugin//` + `plugin/zod-meta.ts`(统一 Zod 提取) | | 加生命周期 hook | `hooks.ts` 定义 + 各子包 `src/index.ts` 的 dispatch/checker 注入 | | 改类型推导 | `api.ts` 的 `registerTyped` 泛型层 | @@ -60,7 +61,7 @@ packages/ # v3.0:框架适配器实现独立子包(npm scope @er 5. **breaking change 登记 MIGRATION.md** —— 公开 API 变动同步更新迁移指南。 6. **`_def` 读取集中** —— Zod 内部结构(`_def.type`/`_def.typeName`/`_def.shape`)只在 `plugin/zod-meta.ts` 读取,且必须通过 `getZodTypeName`/`getZodShape`/`getZodInner`/`extractDocFields` 工具函数(双轨兼容 Zod 3 与 Zod 4)。其他文件禁止直接读 `_def`。 7. **改了就 bump 版本** —— 任何涉及公开 API(接口/类型/行为/依赖声明)的改动,**必须同步 bump 版本号**,不能只改代码不留版本痕迹: - - 根包 `erest` 与受影响的子包(`packages/erest-{express,koa,leizmweb,gen}/`)的 `version` 字段一起改,保持一致(`pnpm publish -r` 整体发布,版本不齐会乱)。 + - 根包 `erest` 与受影响的子包(`packages/erest-{express,koa,leizmweb,h3,gen}/`)的 `version` 字段一起改,保持一致(`pnpm publish -r` 整体发布,版本不齐会乱)。 - 语义化版本:bug 修复/文档/非 breaking 新增 → patch(x.y.Z);新增向后兼容能力 → minor(x.Y.0);breaking → major(X.0.0)。 - 同步在 `MIGRATION.md` 加对应版本章节(即使非 breaking 也要登记新增/修复项)。 - 默认通过 `.github/workflows/release.yml` 自动发版:版本 PR 合入 `main` 且 CI 通过后,维护者推送匹配的 `vX.Y.Z` tag。每个 npm 包须先绑定 Trusted Publisher,详见 `RELEASING.md`。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c93870..191652c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ ## 环境与构建 -建议使用 Node.js 24、pnpm 9。仓库是 pnpm workspace,核心包和四个公开子包一起构建。 +建议使用 Node.js 24、pnpm 9。仓库是 pnpm workspace,核心包和五个公开子包一起构建。 ```bash pnpm install --frozen-lockfile @@ -13,7 +13,7 @@ pnpm run build:packages ``` 源码修改位置、Zod 内部结构访问和版本约定见 [AGENTS.md](AGENTS.md)。 -涉及公开 API/行为/依赖的修改,同步更新五个包的版本和 [MIGRATION.md](MIGRATION.md)。 +涉及公开 API/行为/依赖的修改,同步更新六个包的版本和 [MIGRATION.md](MIGRATION.md)。 ## 常用检查 @@ -25,6 +25,7 @@ pnpm run build:packages | `pnpm run test:contract` | OpenAPI、SDK、运行时的合约专项 | | `pnpm run test:cov` | 源码测试与覆盖率门槛 | | `pnpm test` | 构建核心/子包,验证发布产物并运行示例测试 | +| `pnpm run test:h3-types` | H3 handler 的 Raw/State 与适配器兼容性类型检查 | | `pnpm run test:release` | 发布工具的校验、模拟发布及失败重跑测试 | | `pnpm run bench` | 性能基准 | @@ -33,7 +34,7 @@ pnpm run build:packages ## 合约专项 -`test:contract` 在 Express、Koa、LeizmWeb 下执行同一套 GET/POST/PUT/PATCH/DELETE 定义。 +`test:contract` 在 Express、Koa、LeizmWeb、H3 下执行同一套 GET/POST/PUT/PATCH/DELETE 定义。 测试经公开入口生成 OpenAPI 3、Swagger 2 和完整 Axios ESM SDK,再发起真实 HTTP 请求: - Swagger Parser 检查文档规范;独立 Ajv 校验实际参数与响应。 diff --git a/MIGRATION.md b/MIGRATION.md index a033b2b..a910875 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -5,6 +5,15 @@ 按版本倒序列出升级动作与可见行为变化。使用说明见 `guides/`,开发检查和发布操作分别见 [贡献指南](CONTRIBUTING.md)与[发布流程](RELEASING.md)。 +## v3.4.0 — H3 v2 适配器 + +- 新增独立包 `@erest/h3`,导出 `H3Adapter`、`h3Adapter`、`createERest` 和 `H3Raw`。 +- 支持 H3 `^2.0.8`(Node.js >=20.19);不兼容 H3 v1 的 `createApp/createRouter` API。 +- 复用标准中间件、Zod 分层校验、hooks、响应信封、文档和 SDK;`reply.raw` 推导为 `H3Event`。 +- 支持 `router: new H3()` 及 forceGroup 下的 `router: H3` 分组挂载。 +- 声明 body schema 时自动解析 JSON/URL-encoded,空 body 也会校验;未声明时保留原生请求流。 +- 六个公开包统一为 `3.4.0`,新包纳入 workspace、构建与发布产物验证。 + ## v3.3.1 — SDK 位置参数兼容性与 3.3 系列首次发布 - 恢复已声明 params schema 的 SDK 位置参数顺序,避免字段声明顺序与路由顺序不同时,把既有调用的参数值对调。 diff --git a/README.md b/README.md index 1e002a8..2f46e55 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Test](https://github.com/yourtion/node-erest/actions/workflows/test.yml/badge.svg)](https://github.com/yourtion/node-erest/actions/workflows/test.yml) [![codecov](https://codecov.io/github/yourtion/node-erest/graph/badge.svg)](https://codecov.io/github/yourtion/node-erest) -用一份 Zod schema 定义和校验 API,为 Express、Koa、@leizm/web 生成文档与 Axios SDK。 +用一份 Zod schema 定义和校验 API,为 Express、Koa、@leizm/web、H3 生成文档与 Axios SDK。 TypeScript handler 的参数类型由 schema 推导。 - **统一 API 定义**:支持分组、中间件、分层参数和响应校验。 @@ -24,6 +24,9 @@ npm install erest zod @erest/koa koa @koa/router@^13 koa-bodyparser # @leizm/web 2 npm install erest zod @erest/leizmweb @leizm/web + +# H3 2(需要 Node.js >=20.19) +npm install erest zod @erest/h3 h3@^2.0.8 ``` ## 快速开始 @@ -80,7 +83,7 @@ curl -X POST http://localhost:3000/users \ | 需要做什么 | 文档 | | --- | --- | | 定义 schema、handler、响应、分组与中间件 | [API 定义](guides/api.md) | -| 接入三个框架、配置路由前缀和 TypeScript | [框架与 ESM](guides/adapters.md) | +| 接入四个框架、配置路由前缀和 TypeScript | [框架与 ESM](guides/adapters.md) | | 生成 OpenAPI、其他文档和 Axios SDK | [文档与 SDK](guides/documentation.md) | | 测试业务 API、保存真实示例 | [API 测试](guides/testing.md) | | 运行完整博客示例 | [示例工程](examples/README.md) | diff --git a/RELEASING.md b/RELEASING.md index fb44739..98ecfa3 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -8,8 +8,8 @@ ## 一次性 npm 配置 -维护者须登录 npm,分别进入以下五个包的 Settings → Trusted Publisher,选择 GitHub Actions。 -它们已经在 npm 上存在,无需新包引导流程。仅合入 workflow 不会替代 npm 后台绑定;本仓库无法从代码确认绑定状态。 +维护者须登录 npm,分别进入以下六个包的 Settings → Trusted Publisher,选择 GitHub Actions。 +原有五包已经在 npm 上存在;新增 `@erest/h3` 首发前须先由维护者在 npm 建包并配置 Trusted Publisher。仅合入 workflow 不会替代 npm 后台绑定;本仓库无法从代码确认绑定状态。 | npm 包 | Organization or user | Repository | Workflow filename | Environment name | | --- | --- | --- | --- | --- | @@ -17,6 +17,7 @@ | `@erest/express` | `yourtion` | `node-erest` | `release.yml` | 留空 | | `@erest/koa` | `yourtion` | `node-erest` | `release.yml` | 留空 | | `@erest/leizmweb` | `yourtion` | `node-erest` | `release.yml` | 留空 | +| `@erest/h3` | `yourtion` | `node-erest` | `release.yml` | 留空 | | `@erest/gen` | `yourtion` | `node-erest` | `release.yml` | 留空 | workflow 名称填写文件名 `release.yml`,不要填写显示名称 `Release` 或完整路径。如果 npm 界面 @@ -24,13 +25,13 @@ workflow 名称填写文件名 `release.yml`,不要填写显示名称 `Release 本 workflow 没有 GitHub environment,所以 npm 的 Environment name 留空。将来添加 environment 或重命名 workflow 时,要同步修改每个包的绑定。 -四个子包的 `repository.url` 与根包一致,且包含各自的 `repository.directory`。provenance +五个子包的 `repository.url` 与根包一致,且包含各自的 `repository.directory`。provenance 要求仓库 URL 与实际发布仓库匹配。不要用 token 掩盖 OIDC 配置错误,也不要将凭据写入仓库或日志。 官方说明:[Trusted publishers](https://docs.npmjs.com/trusted-publishers/)。 ## 正式发版 -1. 将根包和四个公开子包的版本统一更新,更新 `MIGRATION.md`,通过 PR 合入 `main`。 +1. 将根包和五个公开子包的版本统一更新,更新 `MIGRATION.md`,通过 PR 合入 `main`。 2. 确认该提交的 CI 通过,且上一次发版已经完成。拉取 `main` 后创建并推送精确 tag: ```bash @@ -41,7 +42,7 @@ workflow 名称填写文件名 `release.yml`,不要填写显示名称 `Release git push origin "v${RELEASE_VERSION}" ``` - `RELEASE_VERSION` 读取当前 manifest 版本;推送前核对五个包的版本一致。`pnpm run tag` + `RELEASE_VERSION` 读取当前 manifest 版本;推送前核对六个包的版本一致。`pnpm run tag` 只创建本地 tag,不执行 npm 发布。也可用 `git tag v` 创建 tag。 3. 在 Actions → Release 查看结果。只有推送 tag 触发发布,普通 PR/main push 不发布。 @@ -60,15 +61,15 @@ tag 必须是严格的 `vX.Y.Z`,支持例如 `v3.3.0-rc.1` 的预发布,拒 - frozen-lockfile 安装、`check`、`test:release`; - 显式执行 `prepublishOnly`(format、根包与子包构建、发布产物测试及示例测试)和覆盖率门槛; - pnpm workspace-aware pack,检查每个真实 tarball 的 manifest、exports/main/types、CLI shebang/执行权限及内部依赖; -- 在临时消费项目安装五个 tarball,验证核心与三框架 adapter 的 ESM 导入以及 codegen CLI 入口; +- 在临时消费项目安装六个 tarball,验证核心与四框架 adapter 的 ESM 导入以及 codegen CLI 入口; - 确认 lifecycle 未改动 tag 的受跟踪源码。 -产物以 `npm-release` Actions artifact 保留 30 天,包含五个 `.tgz` 和记录 SHA-256 的 +产物以 `npm-release` Actions artifact 保留 30 天,包含六个 `.tgz` 和记录 SHA-256 的 `release.json`。发布 job 重新校验全部产物的身份、内容哈希和内部依赖,使用 npm CLI 发布同一批 tarball;不重新 pack 或构建。pnpm 负责 workspace 协议转换,不能直接用 npm 在 workspace 目录打包。声明与实际文件必须一致;通配符 exports 需要先扩展验证器。 -先发布依赖,再发布消费者;当前顺序为 `erest`、三个 adapter、`@erest/gen`。每个包先查询 +先发布依赖,再发布消费者;当前顺序为 `erest`、四个 adapter、`@erest/gen`。每个包先查询 精确版本,已存在则跳过,只有结构化 npm `E404` 才视为缺失。认证、网络、限流、publish 错误均 终止流程。全部必需包完成后才创建或完成 GitHub Release,notes 由 GitHub 生成;tarball 保留在 Actions artifact,不额外附加 GitHub Release assets。 diff --git a/guides/adapters.md b/guides/adapters.md index e0132d2..627c9b9 100644 --- a/guides/adapters.md +++ b/guides/adapters.md @@ -9,6 +9,7 @@ | `@erest/express` | `createERest` / `ExpressAdapter` | Express 4、5 | | `@erest/koa` | `createERest` / `KoaAdapter` | Koa 3,`@koa/router` 13 或 `koa-router` 13 | | `@erest/leizmweb` | `createERest` / `LeizmWebAdapter` | @leizm/web 2 | +| `@erest/h3` | `createERest` / `H3Adapter`(或 `h3Adapter`) | H3 ^2.0.8 | 每个工厂创建的实例都使用相同的 [API 定义方式](api.md),并推导该框架的 `ctx.reply.raw` 类型。 以下绑定片段假定 `api` 已创建并注册路由,通用错误格式器从 `erest` 导入。 @@ -82,6 +83,50 @@ app.server.listen(3000); 需要表单时,添加 `component.bodyParser.urlencoded({ extended: true })`。 +## H3 + +以下是可运行的 H3 v2 示例(Node.js >=20.19),不需要额外 body parser: + +```javascript +import { createServer } from 'node:http'; +import { H3 } from 'h3'; +import { toNodeHandler } from 'h3/node'; +import { z, defaultErrorFormatter } from 'erest'; +import { createERest, h3Adapter } from '@erest/h3'; + +const app = new H3({ + onError(error, event) { + // H3 将原始 erest 错误保存在 cause 中。 + const { status, body } = defaultErrorFormatter(error.cause instanceof Error ? error.cause : error); + event.res.status = status; + return body; + }, +}); +const api = createERest({ groups: { users: '用户' } }); +api.api.post('/users').group('users').registerTyped( + { body: z.object({ name: z.string().min(1) }) }, + (req, ctx) => ctx.reply.status(201).json({ name: req.body.name }), +); +api.bind({ adapter: h3Adapter, router: app }); +createServer(toNodeHandler(app)).listen(3000); +``` + +`H3Adapter` 和单例 `h3Adapter` 等价。只支持 H3 v2;v1 的 `createApp/createRouter` 不适用。 +声明 body schema 时,适配器通过 H3 `readBody()` 解析 JSON 或 URL-encoded;缺少请求体按 `{}` 校验, +非法 JSON 进入错误处理/错误信封。未声明 body schema 时不读取请求流,可用 `ctx.reply.raw.req` +处理文本、multipart 或流。`onRequest` 在解析 body 前触发;校验后可从 `ctx.body` 读取原始解析值。 + +错误可通过 H3 应用的 `onError` 处理,或使用 erest 的 `setResponseEnvelopers({ error })`。 +`reply.raw` 是 `H3Event`,例如 `ctx.reply.raw.res.headers.set('x-trace', '1')` 或 `setCookie(ctx.reply.raw, ...)`。 +要从 `registerTyped` 返回原生 `Response`(下载、重定向、流)并跳过成功信封,先调用 `ctx.reply.markSent()`: + +```typescript +api.api.get('/download').group('users').registerTyped({}, (_req, ctx) => { + ctx.reply.markSent(); + return new Response('id\n1', { headers: { 'content-type': 'text/csv' } }); +}); +``` + ## 路由前缀与分组 `info.basePath` 用于文档和 SDK URL,不会替应用挂载路由。 @@ -106,7 +151,9 @@ const api = createERest({ api.bind({ adapter: new ExpressAdapter(), app, router: express.Router }); ``` -Koa 对应 `router: Router`,LeizmWeb 对应其 `Router` 构造函数;它们使用各自的适配器。 +Koa 对应 `router: Router`,LeizmWeb 对应其 `Router` 构造函数。H3 使用 +`api.bind({ adapter: h3Adapter, app, router: H3 })`,通过 `app.mount()` 挂载各分组。 +非 forceGroup 下也可绑定到独立 H3 实例,再用 `app.mount('/api', router)` 挂载前缀。 ## 原生上下文 @@ -117,7 +164,7 @@ Koa 对应 `router: Router`,LeizmWeb 对应其 `Router` 构造函数;它们 | 重定向 | `.res.redirect(...)` | `.redirect(...)` | `.response.redirect(...)` | | 响应头 | `.res.setHeader(...)` | `.set(...)` | `.response.setHeader(...)` | -使用 raw 的代码遵循宿主框架的语义,不再通用于三个框架。响应头应在发送响应体之前设置。 +使用 raw 的代码遵循宿主框架的语义,不再通用于所有框架。响应头应在发送响应体之前设置。 return 模式下手动发送响应,记得调用 [markSent()](api.md#原生响应)。 自定义适配器实现 `erest` 导出的 `FrameworkAdapter` 接口;实现约定见 [架构导航](../AGENTS.md)。 diff --git a/package.json b/package.json index 96b9764..806709b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "erest", - "version": "3.3.1", - "description": "Framework-agnostic REST API framework with Zod validation, auto docs & test scaffolding. Express/Koa/@leizm/web adapters as separate packages.", + "version": "3.4.0", + "description": "Framework-agnostic REST API framework with Zod validation, auto docs & test scaffolding. Express/Koa/@leizm/web/H3 adapters as separate packages.", "type": "module", "main": "dist/lib/index.js", "types": "dist/lib/index.d.ts", @@ -18,7 +18,7 @@ "scripts": { "doc": "typedoc --excludeExternals --out docs/ src/lib/", "dev": "export ISLIB=1 && vitest --watch", - "test": "npm run build && npm run build:packages && vitest run && pnpm --filter erest-example test", + "test": "npm run build && npm run build:packages && npm run test:h3-types && vitest run && pnpm --filter erest-example test", "test:contract": "ISLIB=1 vitest run src/test/test-contract-consistency.ts src/test/test-openapi.ts", "test:lib": "export ISLIB=1 && vitest run", "test:cov": "export ISLIB=1 && vitest run --coverage", @@ -29,14 +29,15 @@ "test:examples": "pnpm --filter erest-example test", "bench": "export ISLIB=1 && vitest bench --config vitest.bench.config.ts", "tag": "git tag v`node -p 'require(\"./package\").version'`", - "format": "oxfmt --write src examples/src examples/test", - "check": "oxlint src examples/src examples/test && oxfmt --check src examples/src examples/test", + "format": "oxfmt --write src packages/erest-h3/src examples/src examples/test", + "check": "oxlint src packages/erest-h3/src examples/src examples/test && oxfmt --check src packages/erest-h3/src examples/src examples/test", "clean": "rm -rf dist", "build": "npm run clean && tsc", - "build:packages": "pnpm --filter @erest/express --filter @erest/koa --filter @erest/leizmweb --filter @erest/gen build", + "build:packages": "pnpm --filter @erest/express --filter @erest/koa --filter @erest/leizmweb --filter @erest/h3 --filter @erest/gen build", "publish:all": "pnpm publish -r --no-git-checks --access public", "prepublishOnly": "npm run format && npm run test", - "prepare": "husky" + "prepare": "husky", + "test:h3-types": "tsc -p packages/erest-h3/tsconfig.test.json" }, "repository": { "type": "git", @@ -52,7 +53,8 @@ "zod", "typescript", "swagger", - "postman" + "postman", + "h3" ], "author": "Yourtion Guo ", "license": "MIT", @@ -101,7 +103,8 @@ "typedoc": "^0.28.19", "typescript": "^5.8.3", "vitest": "^3.2.4", - "zod": "^4.4.3" + "zod": "^4.4.3", + "h3": "^2.0.8" }, "lint-staged": { "*.{ts,js}": [ diff --git a/packages/erest-express/package.json b/packages/erest-express/package.json index 6bfca0f..c4490cd 100644 --- a/packages/erest-express/package.json +++ b/packages/erest-express/package.json @@ -1,6 +1,6 @@ { "name": "@erest/express", - "version": "3.3.1", + "version": "3.4.0", "repository": { "type": "git", "url": "git+https://github.com/yourtion/node-erest.git", diff --git a/packages/erest-gen/package.json b/packages/erest-gen/package.json index be17e2d..7af5b9f 100644 --- a/packages/erest-gen/package.json +++ b/packages/erest-gen/package.json @@ -1,6 +1,6 @@ { "name": "@erest/gen", - "version": "3.3.1", + "version": "3.4.0", "repository": { "type": "git", "url": "git+https://github.com/yourtion/node-erest.git", diff --git a/packages/erest-h3/package.json b/packages/erest-h3/package.json new file mode 100644 index 0000000..269bc33 --- /dev/null +++ b/packages/erest-h3/package.json @@ -0,0 +1,41 @@ +{ + "name": "@erest/h3", + "version": "3.4.0", + "repository": { + "type": "git", + "url": "git+https://github.com/yourtion/node-erest.git", + "directory": "packages/erest-h3" + }, + "description": "H3 v2 adapter for erest", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "peerDependencies": { + "erest": "workspace:^", + "h3": "^2.0.8" + }, + "devDependencies": { + "erest": "workspace:*", + "h3": "^2.0.8", + "typescript": "^5.8.0" + }, + "engines": { + "node": ">=20.19.0" + } +} diff --git a/packages/erest-h3/src/index.ts b/packages/erest-h3/src/index.ts new file mode 100644 index 0000000..72358f2 --- /dev/null +++ b/packages/erest-h3/src/index.ts @@ -0,0 +1,149 @@ +/** H3 v2 framework adapter. */ +/* eslint-disable no-underscore-dangle -- erest's envelope bridge uses __sent and __returnValue. */ +import { ERest as ERestCtor, compose, wrapWithEnvelope } from "erest"; +import type { API, Context, ERest, Envelopers, FrameworkAdapter, LifecycleHooks, Middleware, Reply } from "erest"; +import { getQuery, getRouterParams, readBody } from "h3"; +import type { H3, H3Event, HTTPMethod } from "h3"; + +/** H3 原生事件,可通过 reply.raw 访问请求、响应头和 event.context。 */ +export type H3Raw = H3Event; + +export class H3Adapter implements FrameworkAdapter { + readonly name = "h3" as const; + + makeParamsChecker(erest: ERest, api: API): T { + const compiled = api.options.compiled; + const requiredOneOf = api.options.requiredOneOf; + const onValidate = erest.getHooks()?.onValidate; + const checker: Middleware = (ctx, next) => { + if (!compiled) return next(); + const layered = compiled.validate(ctx); + const flat = { ...layered.params, ...layered.query, ...layered.body, ...layered.headers }; + if (requiredOneOf) { + for (const names of requiredOneOf) { + if (!names.some((name) => flat[name] !== undefined)) { + throw erest.getError().missingParameter(`one of ${names.join(", ")} is required`); + } + } + } + ctx.$validated = layered; + ctx.$params = flat; + ctx.$pathParams = layered.params; + ctx.$query = layered.query; + ctx.$body = layered.body; + ctx.$headers = layered.headers; + if (onValidate) { + try { + onValidate(ctx, layered); + } catch { + /* hooks are observers */ + } + } + return next(); + }; + return checker as unknown as T; + } + + bindRoute(router: unknown, api: API, handlers: T[], hooks?: LifecycleHooks, envelopers?: Envelopers): void { + // Body parsing runs inside dispatch so parse errors follow the same envelope/hook path. + // Only declared body schemas consume the stream; raw handlers can read other bodies themselves. + const rawDispatch = compose( + api.options.bodySchema + ? [readRequestBody, ...(handlers as unknown as Middleware[])] + : (handlers as unknown as Middleware[]) + ); + const dispatch = envelopers ? wrapWithEnvelope(rawDispatch, envelopers) : rawDispatch; + const hasHook = Boolean(hooks && (hooks.onRequest || hooks.onError || hooks.onResponse)); + (router as H3).on( + String(api.options.method).toUpperCase() as HTTPMethod, + String(api.options.path), + async (event) => { + const reply = createH3Reply(event); + const ctx: Context = { + method: event.req.method.toUpperCase(), + path: event.url.pathname, + headers: Object.fromEntries(event.req.headers), + params: getRouterParams(event, { decode: true }), + query: getQuery(event), + body: {}, + state: {}, + reply, + }; + event.context.$ctx = ctx; + if (hasHook && hooks) { + try { + hooks.onRequest?.(ctx); + } catch { + /* hooks are observers */ + } + try { + await dispatch(ctx); + try { + hooks.onResponse?.(ctx); + } catch { + /* hooks are observers */ + } + } catch (err) { + try { + hooks.onError?.(ctx, err as Error); + } catch { + /* hooks are observers */ + } + throw err; + } + } else { + await dispatch(ctx); + } + // markSent() + registerTyped return supports native Response/streams/redirects. + return reply.body ?? (reply.__sent ? (ctx as Context & { __returnValue?: unknown }).__returnValue : undefined); + } + ); + } + + createGroupRouter(RouterCtor: unknown, _prefix: string): unknown { + return new (RouterCtor as new () => H3)(); + } + + attachGroupRouter(app: unknown, groupRouter: unknown, prefix: string): void { + (app as H3).mount(prefix.startsWith("/") ? prefix : `/${prefix}`, groupRouter as H3); + } +} + +const readRequestBody: Middleware = async (ctx, next) => { + (ctx as { body: unknown }).body = (await readBody(ctx.reply.raw as H3Event)) ?? {}; + await next(); +}; + +function createH3Reply(event: H3Event): Reply & { __sent: boolean; body?: string } { + const reply: Reply & { __sent: boolean; body?: string } = { + __sent: false, + status(code) { + event.res.status = code; + return reply; + }, + json(body) { + reply.body = JSON.stringify(body) ?? ""; + event.res.headers.set("content-type", "application/json; charset=utf-8"); + reply.__sent = true; + }, + send(body) { + reply.body = body; + event.res.headers.set("content-type", "text/plain; charset=utf-8"); + reply.__sent = true; + }, + markSent() { + reply.__sent = true; + }, + raw: event, + }; + return reply; +} + +export const h3Adapter = new H3Adapter(); + +/** 创建锁定 H3Event 原生类型的实例;State 泛型用于跨中间件的 ctx.state。 */ +export function createERest = Record>( + options: ConstructorParameters[0] +): ERest, H3Raw, State> { + return new ERestCtor, H3Raw, State>(options); +} diff --git a/packages/erest-h3/tsconfig.json b/packages/erest-h3/tsconfig.json new file mode 100644 index 0000000..318ebdb --- /dev/null +++ b/packages/erest-h3/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src/**/*"] +} diff --git a/packages/erest-h3/tsconfig.test.json b/packages/erest-h3/tsconfig.test.json new file mode 100644 index 0000000..3b76aef --- /dev/null +++ b/packages/erest-h3/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "../..", + "types": ["node", "vitest/globals"] + }, + "include": ["../../src/test/test-integration-h3.ts"] +} diff --git a/packages/erest-koa/package.json b/packages/erest-koa/package.json index 870143a..8d2aa5f 100644 --- a/packages/erest-koa/package.json +++ b/packages/erest-koa/package.json @@ -1,6 +1,6 @@ { "name": "@erest/koa", - "version": "3.3.1", + "version": "3.4.0", "repository": { "type": "git", "url": "git+https://github.com/yourtion/node-erest.git", diff --git a/packages/erest-leizmweb/package.json b/packages/erest-leizmweb/package.json index feee9b3..3c54cfc 100644 --- a/packages/erest-leizmweb/package.json +++ b/packages/erest-leizmweb/package.json @@ -1,6 +1,6 @@ { "name": "@erest/leizmweb", - "version": "3.3.1", + "version": "3.4.0", "repository": { "type": "git", "url": "git+https://github.com/yourtion/node-erest.git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 042ba86..19b7cdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: express: specifier: ^5.1.0 version: 5.2.1(supports-color@7.2.0) + h3: + specifier: ^2.0.8 + version: 2.0.8 husky: specifier: ^9.1.7 version: 9.1.7 @@ -158,6 +161,18 @@ importers: specifier: ^5.8.0 version: 5.8.3 + packages/erest-h3: + devDependencies: + erest: + specifier: workspace:* + version: link:../.. + h3: + specifier: ^2.0.8 + version: 2.0.8 + typescript: + specifier: ^5.8.0 + version: 5.8.3 + packages/erest-koa: dependencies: '@koa/router': @@ -1848,6 +1863,19 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + h3@2.0.8: + resolution: {integrity: sha512-ENuKlF9kC6E6PrvY1PUyE0qkrnnPTIt2/v48uyEEfTVfgoiqBCE0uwwf++2XrWwT1gxjQBDLQ8PAsmZVniKvTw==} + engines: {node: '>=20.19.0'} + hasBin: true + peerDependencies: + crossws: ^0.4.12 + ocache: '>=0.3.0' + peerDependenciesMeta: + crossws: + optional: true + ocache: + optional: true + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2349,6 +2377,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@1.0.0: + resolution: {integrity: sha512-ySeaQkSrua+ut9g+65I7FHqOFBaY8tV0FmccXItoq0q21se32Q3u4YiW7/RqXSosWgYJaaKwRHXy3BST9PeCNQ==} + engines: {node: '>=20.19.0'} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -2454,6 +2486,11 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + srvx@1.0.8: + resolution: {integrity: sha512-ngFS/J6St4PJfHRHGjr78xkPPtbyXtdMOLNE4x/XZunEK3OhV7aGUWMHf9WTUEy6wm1U0W4okkd82ZFFWRVQTw==} + engines: {node: '>=20.16.0'} + hasBin: true + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -4346,6 +4383,11 @@ snapshots: gopd@1.2.0: {} + h3@2.0.8: + dependencies: + rou3: 1.0.0 + srvx: 1.0.8 + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -4858,6 +4900,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.45.1 fsevents: 2.3.3 + rou3@1.0.0: {} + router@2.2.0(supports-color@7.2.0): dependencies: debug: 4.4.1(supports-color@7.2.0) @@ -5023,6 +5067,8 @@ snapshots: source-map@0.7.6: {} + srvx@1.0.8: {} + stackback@0.0.2: {} statuses@1.5.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 93e188e..3482021 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,10 +1,11 @@ packages: # 端到端示例工程(迷你博客业务域,三框架入口) - 'examples' - # 框架适配器子包(插件化:核心包解耦三框架) + # 框架适配器子包(插件化:核心包解耦四框架) - 'packages/erest-express' - 'packages/erest-koa' - 'packages/erest-leizmweb' + - 'packages/erest-h3' # codegen CLI 子包(从 Zod schema 生成 handler 骨架等) - 'packages/erest-gen' allowBuilds: diff --git a/scripts/npm-release-smoke.mjs b/scripts/npm-release-smoke.mjs index 90d5aaf..5ebaf0e 100644 --- a/scripts/npm-release-smoke.mjs +++ b/scripts/npm-release-smoke.mjs @@ -26,14 +26,26 @@ try { assert.equal(document.openapi, '3.0.3'); assert.equal(document.paths['/items'].post.requestBody.content['application/json'].schema.$ref.startsWith('#/components/schemas/'), true); assert.equal(core.z.string().parse('published package'), 'published package'); - for (const name of ['@erest/express', '@erest/koa', '@erest/leizmweb']) { + for (const name of ['@erest/express', '@erest/koa', '@erest/leizmweb', '@erest/h3']) { const adapter = await import(name); assert.equal(typeof adapter.createERest, 'function', name); } + const { H3 } = await import('h3'); + const { createERest, h3Adapter } = await import('@erest/h3'); + const h3 = new H3(); + const service = createERest({ groups: { demo: 'Demo' } }); + service.api.post('/items/:id').group('demo').registerTyped({ + params: core.z.object({ id: core.z.coerce.number() }), + body: core.z.object({ name: core.z.string() }), + }, (req, ctx) => ctx.reply.status(201).json({ id: req.params.id, name: req.body.name })); + service.bind({ adapter: h3Adapter, router: h3 }); + const response = await h3.request('/items/42', { method: 'POST', body: JSON.stringify({ name: 'packed' }) }); + assert.equal(response.status, 201); + assert.deepEqual(await response.json(), { id: 42, name: 'packed' }); const cli = spawnSync('node_modules/.bin/erest-gen', [], { encoding: 'utf8' }); assert.equal(cli.status, 1); assert.match(cli.stdout + cli.stderr, /erest-gen handler --from/); - console.log('All five packed packages: consumer smoke passed'); + console.log('All six packed packages: consumer smoke passed'); `); console.log(output(execute(process.execPath, ['smoke.mjs'], { cwd: consumer }))); } finally { diff --git a/scripts/npm-release.mjs b/scripts/npm-release.mjs index 6c252fb..123e38d 100644 --- a/scripts/npm-release.mjs +++ b/scripts/npm-release.mjs @@ -170,7 +170,7 @@ export function completeRelease(info, repository, run = execute) { // Explicit public release set: examples is private and is never published. export const packageDirectories = [ '.', 'packages/erest-express', 'packages/erest-koa', - 'packages/erest-leizmweb', 'packages/erest-gen', + 'packages/erest-leizmweb', 'packages/erest-h3', 'packages/erest-gen', ]; const repositoryUrl = 'git+https://github.com/yourtion/node-erest.git'; const dependencyFields = ['dependencies', 'optionalDependencies', 'peerDependencies', 'devDependencies']; diff --git a/scripts/npm-release.test.mjs b/scripts/npm-release.test.mjs index c479d64..fce9743 100644 --- a/scripts/npm-release.test.mjs +++ b/scripts/npm-release.test.mjs @@ -225,7 +225,7 @@ test('create or complete GitHub release with the matching prerelease state', () }); const repository = { type: 'git', url: 'git+https://github.com/yourtion/node-erest.git' }; -const names = ['erest', '@erest/express', '@erest/koa', '@erest/leizmweb', '@erest/gen']; +const names = ['erest', '@erest/express', '@erest/koa', '@erest/leizmweb', '@erest/h3', '@erest/gen']; function fixture(t, version = '3.2.3') { const root = mkdtempSync(join(tmpdir(), 'erest-release-test-')); @@ -237,7 +237,7 @@ function fixture(t, version = '3.2.3') { name: names[index], version, repository, type: 'module', main: './dist/index.js', types: './dist/index.d.ts', }; - if (index > 0 && index < 4) manifest.peerDependencies = { erest: 'workspace:^' }; + if (index > 0 && index < 5) manifest.peerDependencies = { erest: 'workspace:^' }; writeFileSync(join(path, 'package.json'), JSON.stringify(manifest)); } mkdirSync(join(root, 'examples')); @@ -269,7 +269,7 @@ function localPacker(root, changeManifest = (value) => value) { }; } -test('release set includes exactly five public packages, matching versions and repository metadata', (t) => { +test('release set includes exactly six public packages, matching versions and repository metadata', (t) => { const root = fixture(t); const packages = workspaceRelease('v3.2.3', root); assert.deepEqual(packages.map((pkg) => pkg.name), names); @@ -301,7 +301,7 @@ test('workspace-aware pack checks real tarball contents and records integrity fo const root = fixture(t); const destination = join(root, '.bundle'); const bundle = packWorkspace('v3.2.3', destination, root, localPacker(root)); - assert.equal(bundle.packages.length, 5); + assert.equal(bundle.packages.length, 6); const restored = readBundle('v3.2.3', destination, root); assert.deepEqual(restored.map((pkg) => pkg.name), names); const adapter = inspectTarball(restored[1].tarball); @@ -365,7 +365,7 @@ test('release publication resumes after a partial npm failure and completes GitH assert.equal(githubCalls, 0); failPackage = undefined; publishReleaseSet(packages, 'yourtion/node-erest', run); - assert.equal(published.size, 5); + assert.equal(published.size, 6); assert.equal(npmCalls.filter((name) => name === 'erest').length, 1); assert.equal(githubCalls, 2); }); diff --git a/src/lib/adapters/types.ts b/src/lib/adapters/types.ts index cba6c26..17e536e 100644 --- a/src/lib/adapters/types.ts +++ b/src/lib/adapters/types.ts @@ -6,8 +6,8 @@ import type API from "../api.js"; import type ERest from "../index.js"; -/** Known framework types (three built-in adapters); third-party adapters use arbitrary string names */ -export type FrameworkType = "express" | "koa" | "leizmweb" | (string & {}); +/** Known framework types (four built-in adapters); third-party adapters use arbitrary string names */ +export type FrameworkType = "express" | "koa" | "leizmweb" | "h3" | (string & {}); /** Group info interface for adapters */ export interface IAdapterGroupInfo { @@ -19,7 +19,7 @@ export interface IAdapterGroupInfo { /** * Framework adapter interface - * Provides unified interface for different web frameworks (Express/Koa/@leizm/web built-in via subpackages; + * Provides unified interface for different web frameworks (Express/Koa/@leizm/web/H3 built-in via subpackages; * third-party adapters implement this interface with an arbitrary `name`) * * `Raw` 泛型不在接口方法签名中出现——它仅作为类型标记,由具体 adapter 实现类通过 @@ -28,7 +28,7 @@ export interface IAdapterGroupInfo { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Raw 为类型标记,供子类 implements 锁定(见上 JSDoc) export interface FrameworkAdapter { - /** Framework identifier (e.g. "express"/"koa"/"leizmweb", or a custom adapter name) */ + /** Framework identifier (e.g. "express"/"koa"/"leizmweb"/"h3", or a custom adapter name) */ readonly name: string; /** @@ -80,7 +80,7 @@ export type CheckerFunction = (erest: ERest, schema: API) => T; * * 由各 adapter 注入到请求对象(`$reply`),让 registerTyped 的 handler 用统一的 * `reply.json()/status()` 写响应,从而与具体框架解耦——同一份 handler 可被 - * Express / Koa / @leizm/web 三个框架复用。 + * Express / Koa / @leizm/web / H3 四个框架复用。 * * `raw` 是逃生舱:当需要框架特有能力(setCookie/redirect/stream/文件下载等)时, * 通过 `reply.raw` 访问框架原生对象。类型由 `ERest` 的 Raw 泛型驱动, @@ -112,7 +112,7 @@ export interface Reply { * * 由各 adapter 在中间件链最前面构造并注入,让 before/middleware/handler 用统一的 * `(ctx, next)` 签名,无需关心框架原生 ctx/res 差异。同一份中间件可被 - * Express / Koa / @leizm/web 三个框架复用。 + * Express / Koa / @leizm/web / H3 四个框架复用。 * * params/query/body 为框架原始请求数据(校验前的原始值,由 checker 校验后填入 $validated)。 */ diff --git a/src/lib/index.ts b/src/lib/index.ts index 59c7226..7f5f94b 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -574,12 +574,12 @@ class ERest; /** 应用实例 */ app?: unknown; diff --git a/src/test/adapters.ts b/src/test/adapters.ts index 97c02a9..be9dffd 100644 --- a/src/test/adapters.ts +++ b/src/test/adapters.ts @@ -4,7 +4,9 @@ import { ExpressAdapter } from "../../packages/erest-express/src/index.js"; import { KoaAdapter } from "../../packages/erest-koa/src/index.js"; import { LeizmWebAdapter } from "../../packages/erest-leizmweb/src/index.js"; +import { H3Adapter } from "../../packages/erest-h3/src/index.js"; export const expressAdapter = new ExpressAdapter(); export const koaAdapter = new KoaAdapter(); export const leizmwebAdapter = new LeizmWebAdapter(); +export const h3Adapter = new H3Adapter(); diff --git a/src/test/test-contract-consistency.ts b/src/test/test-contract-consistency.ts index 562394f..825e71c 100644 --- a/src/test/test-contract-consistency.ts +++ b/src/test/test-contract-consistency.ts @@ -10,6 +10,8 @@ import express from "express"; import Koa from "koa"; import bodyParser from "koa-bodyparser"; import KoaRouter from "koa-router"; +import { H3 } from "h3"; +import { toNodeHandler } from "h3/node"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { z } from "zod"; import IAPIDoc from "../lib/extend/docs"; @@ -17,7 +19,7 @@ import apiDocs from "../lib/plugin/generate_markdown/apis"; import { buildSwagger } from "../lib/plugin/generate_swagger"; import type { OpenAPIOperation } from "../lib/plugin/generate_openapi"; import type { Context } from "../lib/adapters/types"; -import { expressAdapter, koaAdapter, leizmwebAdapter } from "./adapters"; +import { expressAdapter, koaAdapter, leizmwebAdapter, h3Adapter } from "./adapters"; import lib from "./lib"; const methods = ["get", "post", "put", "patch", "delete"] as const; @@ -42,6 +44,11 @@ type GeneratedSDK = { instance: AxiosInstance } & Record< Record<`${(typeof methods)[number]}ContractBadResponse`, () => Promise>; function bindServer(framework: string, service: ReturnType): Server { + if (framework === "h3") { + const app = new H3(); + service.bind({ adapter: h3Adapter, router: app }); + return createServer(toNodeHandler(app)); + } if (framework === "express") { const app = express(); app.use(express.json()); @@ -64,7 +71,7 @@ function bindServer(framework: string, service: ReturnType): Server return app.server; } -describe.each(["express", "koa", "leizmweb"])("合约一致性:%s", (framework) => { +describe.each(["express", "koa", "leizmweb", "h3"])("合约一致性:%s", (framework) => { let lastRequest: Context; const service = lib({ info: { host: "http://127.0.0.1", basePath: "", title: "Contract" }, diff --git a/src/test/test-integration-h3.ts b/src/test/test-integration-h3.ts new file mode 100644 index 0000000..f03a79d --- /dev/null +++ b/src/test/test-integration-h3.ts @@ -0,0 +1,303 @@ +import { H3, H3Event, HTTPError, setCookie } from "h3"; +import { describe, expect, expectTypeOf, it, vi } from "vitest"; +import { z } from "zod"; +import { defaultErrorFormatter, type Context } from "../lib/index.js"; +import { createERest, h3Adapter, type H3Raw } from "../../packages/erest-h3/src/index.js"; + +function setup() { + const app = new H3({ + silent: true, + onError(error, event) { + const { body, status } = defaultErrorFormatter(error.cause instanceof Error ? error.cause : error); + event.res.status = status; + return body; + }, + }); + const service = createERest({ groups: { test: "Test" } }); + return { app, service }; +} + +describe("H3 v2 integration", () => { + it("validates layered params/query/body/headers and preserves native event types", async () => { + const { app, service } = setup(); + service.api + .post("/items/:id") + .group("test") + .registerTyped( + { + params: z.object({ id: z.coerce.number().int() }), + query: z.object({ tag: z.array(z.string()) }), + body: z.object({ id: z.coerce.number(), name: z.string().trim() }), + headers: z.object({ "x-token": z.string() }), + }, + (req, ctx) => { + expectTypeOf(ctx.reply.raw).toEqualTypeOf(); + expectTypeOf(ctx.reply.raw).toEqualTypeOf(); + expectTypeOf(req.params.id).toEqualTypeOf(); + expect(ctx.reply.raw.context.$ctx).toBe(ctx); + expect(ctx.$pathParams).toEqual({ id: 42 }); + expect(ctx.$body).toEqual({ id: 7, name: "Tom" }); + expect(ctx.$params?.id).toBe(7); + ctx.reply.status(201).json(req); + } + ); + service.bind({ adapter: h3Adapter, router: app }); + const response = await app.request("/items/42?tag=a&tag=b", { + method: "POST", + headers: { "content-type": "application/json", "X-Token": "secret" }, + body: JSON.stringify({ id: "7", name: " Tom " }), + }); + expect(response.status).toBe(201); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(await response.json()).toEqual({ + params: { id: 42 }, + query: { tag: ["a", "b"] }, + body: { id: 7, name: "Tom" }, + headers: { "x-token": "secret" }, + }); + const invalid = await app.request("/items/nope?tag=a&tag=b", { method: "POST", body: "{}" }); + expect(invalid.status).toBe(400); + expect(await invalid.json()).toMatchObject({ code: "INVALID_PARAM" }); + expect((await app.request("/unknown")).status).toBe(404); + }); + + it.each(["post", "put", "patch", "delete"] as const)( + "parses %s bodies and rejects missing or malformed JSON", + async (method) => { + const { app, service } = setup(); + service.api[method]("/body") + .group("test") + .registerTyped({ body: z.object({ name: z.string().min(1) }) }, (req, ctx) => ctx.reply.json(req.body)); + service.bind({ adapter: h3Adapter, router: app }); + const request = (body?: string, contentType = "application/json") => + app.request("/body", { + method: method.toUpperCase(), + headers: { "content-type": contentType }, + body, + }); + expect((await request()).status).toBe(400); + expect((await request("{}")).status).toBe(400); + expect((await request('{"name":')).status).toBe(400); + expect(await (await request('{"name":"Tom"}')).json()).toEqual({ name: "Tom" }); + expect(await (await request("name=Tom", "application/x-www-form-urlencoded")).json()).toEqual({ name: "Tom" }); + } + ); + + it("keeps undeclared bodies available for native stream and multipart handling", async () => { + const { app, service } = setup(); + service.api + .post("/raw") + .group("test") + .registerTyped({}, async (_req, ctx) => { + ctx.reply.send(await ctx.reply.raw.req.text()); + }); + service.bind({ adapter: h3Adapter, router: app }); + const response = await app.request("/raw", { method: "POST", body: "raw content" }); + expect(await response.text()).toBe("raw content"); + expect(response.headers.get("content-type")).toContain("text/plain"); + }); + + it.each([null, false, 0, "", undefined])( + "serializes JSON scalar %s without falling through to a handler return", + async (value) => { + const { app, service } = setup(); + service.api + .get("/scalar") + .group("test") + .registerTyped({}, (_req, ctx) => { + ctx.reply.json(value); + return "ignored"; + }); + service.bind({ adapter: h3Adapter, router: app }); + const response = await app.request("/scalar"); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(await response.text()).toBe(JSON.stringify(value) ?? ""); + } + ); + + it("wraps returned data and errors, including body parsing failures", async () => { + const { app, service } = setup(); + const success = vi.fn((data) => ({ data })); + service.setResponseEnvelopers({ + success, + error: (err) => ({ + status: err instanceof HTTPError ? err.status : 422, + body: { message: (err as Error).message }, + }), + }); + service.api + .post("/envelope") + .group("test") + .registerTyped( + { body: z.object({ count: z.coerce.number().default(2) }), response: z.object({ count: z.number() }) }, + (req) => req.body + ); + service.api + .get("/manual") + .group("test") + .registerTyped({}, (_req, ctx) => { + ctx.reply.status(202).json({ manual: true }); + }); + service.api + .get("/csv") + .group("test") + .registerTyped({}, (_req, ctx) => { + ctx.reply.markSent(); + return new Response("id\n1", { headers: { "content-type": "text/csv" } }); + }); + service.api + .get("/bad-response") + .group("test") + .registerTyped({ response: z.object({ id: z.number() }) }, () => ({ id: "wrong" }) as never); + service.bind({ adapter: h3Adapter, router: app }); + const response = await app.request("/envelope", { method: "POST" }); + expect(await response.json()).toEqual({ data: { count: 2 } }); + const malformed = await app.request("/envelope", { method: "POST", body: "{" }); + expect(malformed.status).toBe(400); + expect(await malformed.json()).toEqual({ message: "Invalid JSON body" }); + expect((await app.request("/bad-response")).status).toBe(422); + const manual = await app.request("/manual"); + expect(manual.status).toBe(202); + expect(await manual.json()).toEqual({ manual: true }); + const csv = await app.request("/csv"); + expect(csv.headers.get("content-type")).toBe("text/csv"); + expect(await csv.text()).toBe("id\n1"); + expect(success).toHaveBeenCalledTimes(1); + }); + + it("mounts explicit/default/nested group prefixes once and composes middleware in order", async () => { + const app = new H3(); + const service = createERest<{ trace: string[] }>({ + forceGroup: true, + groups: { v1: { name: "v1", prefix: "/api/v1" }, UserAdmin: "Admin", root: { name: "root", prefix: "/" } }, + }); + const trace: string[] = []; + service.beforeHooks((ctx, next) => { + ctx.state.trace = trace; + trace.push("global"); + return next(); + }); + const group = service.group("v1"); + group.before((ctx, next) => { + ctx.state.trace.push("group-before"); + return next(); + }); + group.middleware(async (ctx, next) => { + ctx.state.trace.push("group-in"); + await next(); + ctx.state.trace.push("group-out"); + }); + group + .get("/items/:id") + .before((ctx, next) => { + ctx.state.trace.push("route-before"); + return next(); + }) + .middlewares((ctx, next) => { + ctx.state.trace.push("route-middleware"); + return next(); + }) + .registerTyped({ params: z.object({ id: z.string() }) }, (req, ctx) => { + expectTypeOf(ctx.state.trace).toEqualTypeOf(); + trace.push("handler"); + ctx.reply.json({ id: req.params.id, path: ctx.path }); + }); + service + .group("UserAdmin") + .get("/info") + .register((ctx) => ctx.reply.json({ admin: true })); + service + .group("root") + .get("/") + .register((ctx) => ctx.reply.send("root")); + service.bind({ adapter: h3Adapter, app, router: H3 }); + const response = await app.request("/api/v1/items/hello%20world"); + expect(await response.json()).toEqual({ id: "hello world", path: "/api/v1/items/hello%20world" }); + expect(trace).toEqual([ + "global", + "group-before", + "route-before", + "group-in", + "route-middleware", + "handler", + "group-out", + ]); + expect(await (await app.request("/user_admin/info")).json()).toEqual({ admin: true }); + expect(await (await app.request("/")).text()).toBe("root"); + expect((await app.request("/api/v1/api/v1/items/1")).status).toBe(404); + }); + + it("observes lifecycle hooks, swallows observer errors and propagates dispatch errors to H3", async () => { + const events: string[] = []; + const failure = new Error("handler failed"); + const onError = vi.fn((_ctx: Context, err: Error) => { + expect(err).toBe(failure); + events.push("error"); + throw new Error("ignored"); + }); + const service = createERest({ + groups: { test: "Test" }, + hooks: { + onRequest: () => { + events.push("request"); + throw new Error("ignored"); + }, + onValidate: () => { + events.push("validate"); + throw new Error("ignored"); + }, + onResponse: () => { + events.push("response"); + throw new Error("ignored"); + }, + onError, + }, + }); + const app = new H3({ silent: true }); + service.api + .get("/ok") + .group("test") + .register((ctx) => { + setCookie(ctx.reply.raw as H3Event, "session", "value"); + ctx.reply.json("ok"); + }); + service.api + .get("/fail") + .group("test") + .register(() => { + throw failure; + }); + service.bind({ adapter: h3Adapter, router: app }); + const response = await app.request("/ok"); + expect(await response.json()).toBe("ok"); + expect(response.headers.get("set-cookie")).toContain("session=value"); + expect(events).toEqual(["request", "validate", "response"]); + events.length = 0; + expect((await app.request("/fail")).status).toBe(500); + expect(events).toEqual(["request", "validate", "error"]); + expect(onError).toHaveBeenCalledOnce(); + }); + + it("enforces requiredOneOf and lets middleware terminate the chain", async () => { + const { app, service } = setup(); + const handler = vi.fn(); + service.api + .get("/search") + .group("test") + .query(z.object({ name: z.string().optional(), email: z.string().optional() })) + .requiredOneOf(["name", "email"]) + .register((ctx) => ctx.reply.json(ctx.$query)); + service.api + .get("/blocked") + .group("test") + .before((ctx) => ctx.reply.status(403).send("blocked")) + .register(handler); + service.bind({ adapter: h3Adapter, router: app }); + expect((await app.request("/search")).status).toBe(400); + expect(await (await app.request("/search?email=a%40b.com")).json()).toEqual({ email: "a@b.com" }); + const response = await app.request("/blocked"); + expect(response.status).toBe(403); + expect(await response.text()).toBe("blocked"); + expect(handler).not.toHaveBeenCalled(); + }); +}); From a822012bb69f51afe03d00e0d15018191fec0319 Mon Sep 17 00:00:00 2001 From: Yourtion Guo Date: Tue, 27 Oct 2026 13:49:32 +0000 Subject: [PATCH 2/3] chore(release): prepare H3 1.0.0 bootstrap package --- MIGRATION.md | 1 + RELEASING.md | 21 ++++++++++++++ package.json | 3 +- scripts/npm-bootstrap-h3.mjs | 56 ++++++++++++++++++++++++++++++++++++ scripts/npm-release.test.mjs | 39 +++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 scripts/npm-bootstrap-h3.mjs diff --git a/MIGRATION.md b/MIGRATION.md index a910875..2cb1ed2 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -13,6 +13,7 @@ - 支持 `router: new H3()` 及 forceGroup 下的 `router: H3` 分组挂载。 - 声明 body schema 时自动解析 JSON/URL-encoded,空 body 也会校验;未声明时保留原生请求流。 - 六个公开包统一为 `3.4.0`,新包纳入 workspace、构建与发布产物验证。 +- 新增 `pack:h3-bootstrap`,为首次本地建包准备 `@erest/h3@1.0.0`(依赖已发布的 `erest ^3.3.1`);绑定 Trusted Publisher 后再统一自动发布 `3.4.0`。 ## v3.3.1 — SDK 位置参数兼容性与 3.3 系列首次发布 diff --git a/RELEASING.md b/RELEASING.md index 98ecfa3..76e12b5 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -29,6 +29,27 @@ workflow 名称填写文件名 `release.yml`,不要填写显示名称 `Release 要求仓库 URL 与实际发布仓库匹配。不要用 token 掩盖 OIDC 配置错误,也不要将凭据写入仓库或日志。 官方说明:[Trusted publishers](https://docs.npmjs.com/trusted-publishers/)。 +### @erest/h3 首次本地发布 1.0.0 + +在本地检出包含 H3 适配器的代码,使用 Node.js 24、pnpm 9,并登录有 `@erest` scope 发布权限的 npm 账号: + +```bash +pnpm install --frozen-lockfile +pnpm run pack:h3-bootstrap +npm login +npm publish ./erest-h3-1.0.0.tgz --access public --tag latest --ignore-scripts +``` + +`pack:h3-bootstrap` 构建并检查核心与 H3 子包,在临时目录生成 `@erest/h3@1.0.0`, +输出根目录的 `erest-h3-1.0.0.tgz`。包内 peer 依赖为已发布的 `erest ^3.3.1`, +不含 `workspace:` 或开发依赖;准备命令只打包,不执行发布。 +仓库 manifest 保持六包统一的 `3.4.0`,无需手动改版本再还原,也不要为这次建包推送 `v1.0.0` tag。 + +首次发布成功后,进入 的 +Settings → Trusted Publisher,填写上表中的 GitHub 用户、仓库和 workflow,Environment name 留空。 +配置完成后合入 PR,再按下节推送 `v3.4.0`,由 GitHub Actions 将六包自动发布到 `3.4.0`。 +`1.0.0` 只用于此次建包;后续 H3 版本跟随 erest,不单独递增。 + ## 正式发版 1. 将根包和五个公开子包的版本统一更新,更新 `MIGRATION.md`,通过 PR 合入 `main`。 diff --git a/package.json b/package.json index 806709b..a1eaa64 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "publish:all": "pnpm publish -r --no-git-checks --access public", "prepublishOnly": "npm run format && npm run test", "prepare": "husky", - "test:h3-types": "tsc -p packages/erest-h3/tsconfig.test.json" + "test:h3-types": "tsc -p packages/erest-h3/tsconfig.test.json", + "pack:h3-bootstrap": "npm run build && pnpm --filter @erest/h3 build && npm run test:h3-types && node scripts/npm-bootstrap-h3.mjs" }, "repository": { "type": "git", diff --git a/scripts/npm-bootstrap-h3.mjs b/scripts/npm-bootstrap-h3.mjs new file mode 100644 index 0000000..44bbd67 --- /dev/null +++ b/scripts/npm-bootstrap-h3.mjs @@ -0,0 +1,56 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { execute, inspectTarball, output, releaseInfo } from './npm-release.mjs'; + +// First local publication creates the npm package so its owner can configure OIDC. +// Keep the source version coordinated with the rest of the workspace. +export function bootstrapManifest(source) { + if (source.name !== '@erest/h3') throw new Error('Expected @erest/h3'); + const manifest = structuredClone(source); + manifest.version = '1.0.0'; + manifest.peerDependencies.erest = '^3.3.1'; + delete manifest.devDependencies; + delete manifest.scripts; + releaseInfo('v1.0.0', manifest); + for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) { + if (Object.values(manifest[field] ?? {}).some((range) => range.startsWith('workspace:'))) { + throw new Error('Bootstrap package must not contain workspace dependencies'); + } + } + return manifest; +} + +export function packH3Bootstrap(destination = '.', root = process.cwd(), run = execute) { + const source = resolve(root, 'packages/erest-h3'); + const manifest = bootstrapManifest(JSON.parse(readFileSync(resolve(source, 'package.json'), 'utf8'))); + const directory = resolve(destination); + mkdirSync(directory, { recursive: true }); + const staging = mkdtempSync(resolve(tmpdir(), 'erest-h3-bootstrap-')); + try { + cpSync(resolve(source, 'dist'), resolve(staging, 'dist'), { recursive: true }); + writeFileSync(resolve(staging, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); + // This is an isolated package with no workspace protocols or lifecycle scripts. + output(run('npm', ['pack', '--ignore-scripts', '--pack-destination', directory], { cwd: staging })); + const tarball = resolve(directory, 'erest-h3-1.0.0.tgz'); + const packed = inspectTarball(tarball); + releaseInfo('v1.0.0', packed.manifest); + if (packed.manifest.name !== '@erest/h3' || packed.manifest.peerDependencies?.erest !== '^3.3.1') { + throw new Error('Unexpected bootstrap package identity or erest dependency'); + } + return tarball; + } finally { + rmSync(staging, { recursive: true, force: true }); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + if (process.argv.length > 3) throw new Error('Usage: npm-bootstrap-h3.mjs [destination]'); + console.log(packH3Bootstrap(process.argv[2])); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/npm-release.test.mjs b/scripts/npm-release.test.mjs index fce9743..e3b5e82 100644 --- a/scripts/npm-release.test.mjs +++ b/scripts/npm-release.test.mjs @@ -4,6 +4,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; +import { bootstrapManifest, packH3Bootstrap } from './npm-bootstrap-h3.mjs'; import { completeRelease, isPublished, @@ -39,6 +40,44 @@ const failure = (code) => ({ stderr: code, }); +test('H3 bootstrap is installable against the published core without changing coordinated source versions', () => { + const source = { + name: '@erest/h3', version: '3.4.0', + scripts: { prepublishOnly: 'must not run' }, + peerDependencies: { erest: 'workspace:^', h3: '^2.0.8' }, + devDependencies: { erest: 'workspace:*' }, + }; + const original = structuredClone(source); + const manifest = bootstrapManifest(source); + assert.equal(manifest.version, '1.0.0'); + assert.equal(manifest.peerDependencies.erest, '^3.3.1'); + assert.equal(manifest.peerDependencies.h3, '^2.0.8'); + assert.equal(manifest.devDependencies, undefined); + assert.equal(manifest.scripts, undefined); + assert.deepEqual(source, original); + assert.throws(() => bootstrapManifest({ ...source, name: 'erest' }), /Expected @erest\/h3/); + assert.throws(() => bootstrapManifest({ ...source, dependencies: { other: 'workspace:*' } }), /workspace/); +}); + +test('failed H3 bootstrap packing cleans staging and preserves the workspace manifest', (t) => { + const root = mkdtempSync(join(tmpdir(), 'erest-bootstrap-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const source = join(root, 'packages/erest-h3'); + mkdirSync(join(source, 'dist'), { recursive: true }); + const manifest = JSON.stringify({ name: '@erest/h3', version: '3.4.0', peerDependencies: { erest: 'workspace:^' } }); + writeFileSync(join(source, 'package.json'), manifest); + let staging; + assert.throws(() => packH3Bootstrap(join(root, 'output'), root, (command, args, options) => { + assert.equal(command, 'npm'); + assert.equal(args[0], 'pack'); + assert.ok(args.includes('--ignore-scripts')); + staging = options.cwd; + return { status: 1, stderr: 'pack failed' }; + }), /pack failed/); + assert.throws(() => readFileSync(join(staging, 'package.json')), { code: 'ENOENT' }); + assert.equal(readFileSync(join(source, 'package.json'), 'utf8'), manifest); +}); + test('stable and prerelease tags choose distinct npm channels', () => { assert.equal(info.distTag, 'latest'); for (const version of ['1.2.3-beta.1', '1.2.3-0', '1.2.3-1alpha']) { From 48bf502e3208fd232b8c209a21755d472df37aa9 Mon Sep 17 00:00:00 2001 From: Yourtion Guo Date: Tue, 27 Oct 2026 14:07:46 +0000 Subject: [PATCH 3/3] chore(release): remove completed H3 bootstrap workflow --- MIGRATION.md | 1 - RELEASING.md | 24 ++-------------- package.json | 3 +- scripts/npm-bootstrap-h3.mjs | 56 ------------------------------------ scripts/npm-release.test.mjs | 39 ------------------------- 5 files changed, 3 insertions(+), 120 deletions(-) delete mode 100644 scripts/npm-bootstrap-h3.mjs diff --git a/MIGRATION.md b/MIGRATION.md index 2cb1ed2..a910875 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -13,7 +13,6 @@ - 支持 `router: new H3()` 及 forceGroup 下的 `router: H3` 分组挂载。 - 声明 body schema 时自动解析 JSON/URL-encoded,空 body 也会校验;未声明时保留原生请求流。 - 六个公开包统一为 `3.4.0`,新包纳入 workspace、构建与发布产物验证。 -- 新增 `pack:h3-bootstrap`,为首次本地建包准备 `@erest/h3@1.0.0`(依赖已发布的 `erest ^3.3.1`);绑定 Trusted Publisher 后再统一自动发布 `3.4.0`。 ## v3.3.1 — SDK 位置参数兼容性与 3.3 系列首次发布 diff --git a/RELEASING.md b/RELEASING.md index 76e12b5..88c66ac 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -9,7 +9,8 @@ ## 一次性 npm 配置 维护者须登录 npm,分别进入以下六个包的 Settings → Trusted Publisher,选择 GitHub Actions。 -原有五包已经在 npm 上存在;新增 `@erest/h3` 首发前须先由维护者在 npm 建包并配置 Trusted Publisher。仅合入 workflow 不会替代 npm 后台绑定;本仓库无法从代码确认绑定状态。 +六个包均已在 npm 上存在,`@erest/h3` 已首次手动发布 `3.3.0`,后续随 erest 统一发布 `3.4.0`。 +Trusted Publisher 绑定由包维护者在 npm 完成;仅合入 workflow 不会替代 npm 后台配置。 | npm 包 | Organization or user | Repository | Workflow filename | Environment name | | --- | --- | --- | --- | --- | @@ -29,27 +30,6 @@ workflow 名称填写文件名 `release.yml`,不要填写显示名称 `Release 要求仓库 URL 与实际发布仓库匹配。不要用 token 掩盖 OIDC 配置错误,也不要将凭据写入仓库或日志。 官方说明:[Trusted publishers](https://docs.npmjs.com/trusted-publishers/)。 -### @erest/h3 首次本地发布 1.0.0 - -在本地检出包含 H3 适配器的代码,使用 Node.js 24、pnpm 9,并登录有 `@erest` scope 发布权限的 npm 账号: - -```bash -pnpm install --frozen-lockfile -pnpm run pack:h3-bootstrap -npm login -npm publish ./erest-h3-1.0.0.tgz --access public --tag latest --ignore-scripts -``` - -`pack:h3-bootstrap` 构建并检查核心与 H3 子包,在临时目录生成 `@erest/h3@1.0.0`, -输出根目录的 `erest-h3-1.0.0.tgz`。包内 peer 依赖为已发布的 `erest ^3.3.1`, -不含 `workspace:` 或开发依赖;准备命令只打包,不执行发布。 -仓库 manifest 保持六包统一的 `3.4.0`,无需手动改版本再还原,也不要为这次建包推送 `v1.0.0` tag。 - -首次发布成功后,进入 的 -Settings → Trusted Publisher,填写上表中的 GitHub 用户、仓库和 workflow,Environment name 留空。 -配置完成后合入 PR,再按下节推送 `v3.4.0`,由 GitHub Actions 将六包自动发布到 `3.4.0`。 -`1.0.0` 只用于此次建包;后续 H3 版本跟随 erest,不单独递增。 - ## 正式发版 1. 将根包和五个公开子包的版本统一更新,更新 `MIGRATION.md`,通过 PR 合入 `main`。 diff --git a/package.json b/package.json index a1eaa64..806709b 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,7 @@ "publish:all": "pnpm publish -r --no-git-checks --access public", "prepublishOnly": "npm run format && npm run test", "prepare": "husky", - "test:h3-types": "tsc -p packages/erest-h3/tsconfig.test.json", - "pack:h3-bootstrap": "npm run build && pnpm --filter @erest/h3 build && npm run test:h3-types && node scripts/npm-bootstrap-h3.mjs" + "test:h3-types": "tsc -p packages/erest-h3/tsconfig.test.json" }, "repository": { "type": "git", diff --git a/scripts/npm-bootstrap-h3.mjs b/scripts/npm-bootstrap-h3.mjs deleted file mode 100644 index 44bbd67..0000000 --- a/scripts/npm-bootstrap-h3.mjs +++ /dev/null @@ -1,56 +0,0 @@ -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { execute, inspectTarball, output, releaseInfo } from './npm-release.mjs'; - -// First local publication creates the npm package so its owner can configure OIDC. -// Keep the source version coordinated with the rest of the workspace. -export function bootstrapManifest(source) { - if (source.name !== '@erest/h3') throw new Error('Expected @erest/h3'); - const manifest = structuredClone(source); - manifest.version = '1.0.0'; - manifest.peerDependencies.erest = '^3.3.1'; - delete manifest.devDependencies; - delete manifest.scripts; - releaseInfo('v1.0.0', manifest); - for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) { - if (Object.values(manifest[field] ?? {}).some((range) => range.startsWith('workspace:'))) { - throw new Error('Bootstrap package must not contain workspace dependencies'); - } - } - return manifest; -} - -export function packH3Bootstrap(destination = '.', root = process.cwd(), run = execute) { - const source = resolve(root, 'packages/erest-h3'); - const manifest = bootstrapManifest(JSON.parse(readFileSync(resolve(source, 'package.json'), 'utf8'))); - const directory = resolve(destination); - mkdirSync(directory, { recursive: true }); - const staging = mkdtempSync(resolve(tmpdir(), 'erest-h3-bootstrap-')); - try { - cpSync(resolve(source, 'dist'), resolve(staging, 'dist'), { recursive: true }); - writeFileSync(resolve(staging, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); - // This is an isolated package with no workspace protocols or lifecycle scripts. - output(run('npm', ['pack', '--ignore-scripts', '--pack-destination', directory], { cwd: staging })); - const tarball = resolve(directory, 'erest-h3-1.0.0.tgz'); - const packed = inspectTarball(tarball); - releaseInfo('v1.0.0', packed.manifest); - if (packed.manifest.name !== '@erest/h3' || packed.manifest.peerDependencies?.erest !== '^3.3.1') { - throw new Error('Unexpected bootstrap package identity or erest dependency'); - } - return tarball; - } finally { - rmSync(staging, { recursive: true, force: true }); - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { - try { - if (process.argv.length > 3) throw new Error('Usage: npm-bootstrap-h3.mjs [destination]'); - console.log(packH3Bootstrap(process.argv[2])); - } catch (error) { - console.error(error.message); - process.exitCode = 1; - } -} diff --git a/scripts/npm-release.test.mjs b/scripts/npm-release.test.mjs index e3b5e82..fce9743 100644 --- a/scripts/npm-release.test.mjs +++ b/scripts/npm-release.test.mjs @@ -4,7 +4,6 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; -import { bootstrapManifest, packH3Bootstrap } from './npm-bootstrap-h3.mjs'; import { completeRelease, isPublished, @@ -40,44 +39,6 @@ const failure = (code) => ({ stderr: code, }); -test('H3 bootstrap is installable against the published core without changing coordinated source versions', () => { - const source = { - name: '@erest/h3', version: '3.4.0', - scripts: { prepublishOnly: 'must not run' }, - peerDependencies: { erest: 'workspace:^', h3: '^2.0.8' }, - devDependencies: { erest: 'workspace:*' }, - }; - const original = structuredClone(source); - const manifest = bootstrapManifest(source); - assert.equal(manifest.version, '1.0.0'); - assert.equal(manifest.peerDependencies.erest, '^3.3.1'); - assert.equal(manifest.peerDependencies.h3, '^2.0.8'); - assert.equal(manifest.devDependencies, undefined); - assert.equal(manifest.scripts, undefined); - assert.deepEqual(source, original); - assert.throws(() => bootstrapManifest({ ...source, name: 'erest' }), /Expected @erest\/h3/); - assert.throws(() => bootstrapManifest({ ...source, dependencies: { other: 'workspace:*' } }), /workspace/); -}); - -test('failed H3 bootstrap packing cleans staging and preserves the workspace manifest', (t) => { - const root = mkdtempSync(join(tmpdir(), 'erest-bootstrap-test-')); - t.after(() => rmSync(root, { recursive: true, force: true })); - const source = join(root, 'packages/erest-h3'); - mkdirSync(join(source, 'dist'), { recursive: true }); - const manifest = JSON.stringify({ name: '@erest/h3', version: '3.4.0', peerDependencies: { erest: 'workspace:^' } }); - writeFileSync(join(source, 'package.json'), manifest); - let staging; - assert.throws(() => packH3Bootstrap(join(root, 'output'), root, (command, args, options) => { - assert.equal(command, 'npm'); - assert.equal(args[0], 'pack'); - assert.ok(args.includes('--ignore-scripts')); - staging = options.cwd; - return { status: 1, stderr: 'pack failed' }; - }), /pack failed/); - assert.throws(() => readFileSync(join(staging, 'package.json')), { code: 'ENOENT' }); - assert.equal(readFileSync(join(source, 'package.json'), 'utf8'), manifest); -}); - test('stable and prerelease tags choose distinct npm channels', () => { assert.equal(info.distTag, 'latest'); for (const version of ['1.2.3-beta.1', '1.2.3-0', '1.2.3-1alpha']) {