From 727b2c0ccee496e5ddd91ed91213fbb34aebc214 Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 29 Jun 2026 08:35:06 +0800 Subject: [PATCH 01/14] =?UTF-8?q?fix(leizmweb):=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=AF=B9=E9=BD=90=20express/koa=EF=BC=8Cre-t?= =?UTF-8?q?hrow=20=E7=BB=99=20app=20=E7=BA=A7=E9=94=99=E8=AF=AF=E4=B8=AD?= =?UTF-8?q?=E9=97=B4=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bindRoute 的 .catch 由吞错写死 {error} 改为 ctx.next(err) re-throw,与 express adapter (next(err))/ koa adapter(throw err)+ README「app 级错误中间件」约定一致。此前 leizmweb 是 唯一特例:吞错使 app 级错误中间件失效、丢失 ERestError.code、无法自定义信封。 补 test-integration-leizmweb 错误 re-throw 集成测试(实跑验证 app 级中间件收到错并写信封)。 全量 280 测试通过(core 267 + examples 13)。 --- packages/erest-leizmweb/src/index.ts | 10 +++--- src/test/test-integration-leizmweb.ts | 45 ++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/erest-leizmweb/src/index.ts b/packages/erest-leizmweb/src/index.ts index e8733e6..6bafcc3 100644 --- a/packages/erest-leizmweb/src/index.ts +++ b/packages/erest-leizmweb/src/index.ts @@ -68,7 +68,7 @@ export class LeizmWebAdapter implements FrameworkAdapter { const hasHook = Boolean(hooks && (hooks.onRequest || hooks.onValidate || hooks.onError || hooks.onResponse)); // 单参数中间件(length=1),避免被 @leizm/web 误判为错误处理中间件 const nativeMiddleware = ( - ctx: Record & { request: Record; next: () => void } + ctx: Record & { request: Record; next: (err?: unknown) => void } ) => { const request = ctx.request ?? {}; const reply = createLeiReply(ctx); @@ -109,11 +109,9 @@ export class LeizmWebAdapter implements FrameworkAdapter { /* ignore */ } } - const e = err as { statusCode?: number; status?: number; message?: string }; - const code = e?.statusCode || e?.status || 500; - const leiRes = (ctx.response ?? {}) as { status?: (c: number) => unknown; json?: (b: unknown) => void }; - leiRes.status?.(code); - leiRes.json?.({ error: e?.message || "internal error" }); + // 与 express/koa adapter 对齐:re-throw 给 app 级错误中间件,而非在此吞错写死 {error}。 + // nativeMiddleware 是单参数普通中间件,ctx.next(err) 会触发 @leizm/web 的双参数错误中间件。 + ctx.next(err as Error); }); }; routerTyped[method].bind(router)(api.options.path, nativeMiddleware); diff --git a/src/test/test-integration-leizmweb.ts b/src/test/test-integration-leizmweb.ts index 18cd7b2..7bfafc9 100644 --- a/src/test/test-integration-leizmweb.ts +++ b/src/test/test-integration-leizmweb.ts @@ -12,7 +12,7 @@ import { leizmwebAdapter } from "./adapters"; import { Application, component, Router } from "@leizm/web"; -import { afterAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { z } from "zod"; import lib from "./lib"; @@ -132,3 +132,46 @@ describe("@leizm/web Integration - bind() forceGroup 模式", () => { expect(ret).toBeInstanceOf(Error); }); }); + +/** + * 错误 re-throw 能力验证:adapter 不应吞错写死 {error},而应 re-throw 让 app 级错误中间件 + * 统一处理(与 express/koa adapter + README「app 级错误中间件」约定对齐)。 + * 用真实 HTTP fetch 验证(不走 test 脚手架的 .error(),因其依赖框架错误响应形状)。 + */ +describe("@leizm/web Integration - 错误 re-throw", () => { + const app = new Application(); + app.use("/", component.bodyParser.json()); + + const apiService = lib({ basePath: "" }); + const { api } = apiService; + const router = new Router(); + + // 故意抛一个带 statusCode 的业务错误 + api.get("/lei/boom").group("Index").title("抛错").register((ctx: any) => { + const e: any = new Error("业务错误"); + e.statusCode = 418; + throw e; + }); + + apiService.bind({ adapter: leizmwebAdapter, router }); + app.use("/", router); + // app 级错误中间件(双参数):捕获 re-throw 的错并写自定义信封 + app.use("/", ((ctx: any, err?: any) => { + if (!err) return ctx.next(); + ctx.response.status(err.statusCode || 500).json({ success: false, error: { code: "ERR", message: err.message } }); + }) as any); + + let port = 0; + beforeAll(async () => { + await new Promise((resolve) => app.server.listen(0, "127.0.0.1", resolve)); + port = (app.server.address() as any).port; + }); + afterAll(() => app.server.close()); + + it("handler 抛错应被 app 级错误中间件捕获(re-throw,非吞错)", async () => { + const res = await fetch(`http://127.0.0.1:${port}/lei/boom`); + expect(res.status).toBe(418); + const body = await res.json(); + expect(body).toStrictEqual({ success: false, error: { code: "ERR", message: "业务错误" } }); + }); +}); From 2614a00d806562979ca0c29d4ae3f24ac94a4772 Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 29 Jun 2026 11:13:02 +0800 Subject: [PATCH 02/14] =?UTF-8?q?docs(spec):=20raw=20=E9=80=83=E7=94=9F?= =?UTF-8?q?=E8=88=B1(reply.raw)=20+=20=E6=A1=86=E6=9E=B6=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入框架泛型驱动的 reply.raw 逃生舱,让 registerTyped handler 能拿到强类型 原生对象(setCookie/redirect/stream 等);审计三框架能力差异并提供可选统一 错误格式器。错误处理 re-throw 语义不动(commit 727b2c0 已对齐)。 --- ...w-escape-hatch-and-capability-alignment.md | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-29-raw-escape-hatch-and-capability-alignment.md diff --git a/docs/superpowers/specs/2026-06-29-raw-escape-hatch-and-capability-alignment.md b/docs/superpowers/specs/2026-06-29-raw-escape-hatch-and-capability-alignment.md new file mode 100644 index 0000000..00cc697 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-raw-escape-hatch-and-capability-alignment.md @@ -0,0 +1,255 @@ +# raw 逃生舱(reply.raw)+ 框架能力对齐 + +- 日期:2026-06-29 +- 分支:`feat/adapter-setcookie-error-align` +- 状态:设计已确认,待 plan +- 关联:依赖 v3.0 架构(`adapters/types.ts` 的 Context/Reply/Middleware);错误处理对齐已由 commit `727b2c0` 完成(三框架统一 re-throw 给 app 级错误中间件) + +## 目标 + +v3.0 的标准化抽象(Context/Reply/Middleware)让 before/middleware/handler 能跨框架复用,但 `Reply` 只有 `status/json/send` 三个方法,导致 setCookie、redirect、setHeader、流式响应、文件下载等底层能力在 `registerTyped` 的 handler 里**完全无法访问**。逐个给 Reply 补方法的方案不可行——Express `res.cookie()`、Koa `ctx.cookies.set()`、leizmweb 各不相同,永远对不齐。 + +本次设计引入一个**框架泛型驱动的 raw 逃生舱**(`reply.raw`),让 handler 能拿到强类型的框架原生对象;同时审计三框架的其它能力差异,提供可选的统一错误响应格式器。 + +> 范围限定:raw 逃生舱 + 能力对齐审计。错误处理(re-throw 语义)已对齐,本次不动。 + +--- + +## §1 — 核心设计:reply.raw + 全自动 Raw 泛型 + +### 当前问题(证据) + +- `Reply` 接口(`src/lib/adapters/types.ts:72-79`)只有 `status/json/send`,无原生对象访问入口 +- 三框架 adapter 的 `nativeMiddleware` 闭包内**本就持有原生对象**(Express 的 `req/res`、Koa 的 `ctx`、leizmweb 的 `ctx`),构造标准 Context 时却没把它暴露出来——这是纯粹的接口缺口,不是能力缺口 +- `reply.json()` → `expressRes.json()`(`packages/erest-express/src/index.ts:141`);同理 Koa/leizmweb,均无 raw 注入点 +- 现有泛型 `ERest` 的 `T` 表示 handler/中间件类型(`DEFAULT_HANDLER`),三框架下 `T` 同值,框架差异未进类型层 + +### 1.1 Reply 接口加泛型与 raw 字段 + +```ts +// src/lib/adapters/types.ts +export interface Reply { + status(code: number): this; + json(body: unknown): void; + send(body: string): void; + /** 框架原生对象逃生舱(setCookie/redirect/stream/文件下载等) */ + readonly raw: Raw; +} +``` + +`Context.reply` 类型随之变为 `Reply`(核心层框架无关)。**Context 本身不加 raw**——仅 handler 拿 raw,before/middleware/hook 保持纯标准 Context。 + +### 1.2 TypeScript 硬约束:Raw 必须在构造时锁定 + +`bind()` 是运行时方法,无法反向修改 `this` 的泛型参数: + +```ts +const api = new ERest(); // 这一刻 Raw 定死成 unknown +api.bind({ adapter: new ExpressAdapter() }); // 这一步无法把 Raw 收紧成 ExpressRaw +``` + +故「自动」唯一可行的落点是**构造时锁定**,由子包提供工厂函数: + +```ts +import { createERest } from "@erest/express"; +const api = createERest({ info, groups, forceGroup }); +// ^? ERest ← Raw 已自动锁定 +``` + +### 1.3 泛型透传链路(重构 6 处) + +``` +ERest API IGroup + │ │ │ + └─ group() ──→ get/post() ──→ registerTyped(handler 的 reply: Reply) + +子包工厂 createERest(): ERest +``` + +| 位置 | 改动 | +|------|------| +| `types.ts` | `Reply` 加 `raw: Raw` 字段;`FrameworkAdapter` 加 Raw 维度 | +| `api.ts` | `API` → `API`;`registerTyped` 的 handler reply 类型 `Reply` → `Reply`(Raw 从类泛型透传,**不污染**现有 TQuery/TBody/TParams/THeaders/TResponse 5 个方法级泛型,两者正交) | +| `index.ts` | `ERest` → `ERest`;`IGroup` → `IGroup`;`group()` 透传 Raw | +| 3 子包 | `createXxxReply` 塞 raw 值;新增 `createERest()` 工厂 + 导出 `ExpressRaw`/`KoaRaw`/`LeizmWebRaw` 类型 | + +### 1.4 使用形态 + +```ts +// 入口:子包工厂,Raw 自动锁定 +import { createERest } from "@erest/express"; +const api = createERest({ info: API_INFO, groups: GROUPS, forceGroup: true }); + +// handler:零标注,reply.raw 自动强类型 +api.group("auth").post("/login") + .registerTyped( + { body: LoginSchema }, + (req, reply) => { + const user = authenticate(req.body); + reply.raw.res.cookie("token", sign(user), { httpOnly: true }); + // ^? { req: Request; res: Response } ← 自动推导 + reply.status(201).json({ ok: true }); + } + ); + +// 过渡期兼容:直接 new ERest() 仍可用(标记 @deprecated),Raw 退化为 unknown +import { ERest } from "erest"; +const api2 = new ERest({ ... }); // reply.raw 类型为 unknown,需手动断言;新代码请用 createERest() +``` + +### 1.5 三框架 adapter 改动(热路径零分配) + +```ts +// express: packages/erest-express/src/index.ts +export type ExpressRaw = { req: Request; res: Response }; +export class ExpressAdapter implements FrameworkAdapter { ... } + +function createExpressReply(req, res): Reply { + const reply: Reply = { /* status/json/send 不变 */ }; + reply.raw = { req, res }; // 闭包本就持有 req/res,多挂一个引用 + return reply; +} + +// Koa: reply.raw = ctx; (KoaRaw = KoaContext) +// leizmweb: reply.raw = ctx; (LeizmWebRaw = LeiContext) + +// 子包工厂(构造时锁定 Raw) +export function createERest(options): ERest { + return new ERest(options); +} +``` + +### 1.6 兼容策略 + +废弃裸 `new ERest()`(标记 `@deprecated`,过渡期保留)。裸构造时 `Raw = unknown`,`reply.raw` 可用但需断言。现有 examples 和用户代码**零改动**通过;新代码引导到子包 `createERest()`。 + +--- + +## §2 — 能力对齐审计 + +raw 逃生舱天然覆盖了大部分「对不齐」的能力(stream/cookie/headersSent/redirect),因为本质都是「需要原生对象」。本次**不补 Reply 方法、不抹平差异**,仅通过 raw 暴露 + 文档标注。 + +### 2.1 原生能力差异速查表(文档新增) + +| 能力 | Express (`reply.raw`) | Koa (`reply.raw`) | @leizm/web (`reply.raw`) | +|------|------|------|------| +| 设置 cookie | `.res.cookie(name, val, opts)` | `.cookies.set(name, val, opts)` | `.response.cookie(...)` | +| 读取 cookie | 需 `cookie-parser`,`.req.cookies` | `.cookies.get(name)` | `.request.cookies` | +| 重定向 | `.res.redirect(code, url)` | `.redirect(url)` / `ctx.status=302` | `.response.redirect(...)` | +| 流式响应 | `.res.write()` / `.res.end()` | `.body = stream` | `.response.send()` | +| 响应头已发送 | `.res.headersSent` | `.res.headersSent` | 查原生 | + +### 2.2 已对齐项(本次不动) + +- **错误处理**:三框架统一 re-throw 给 app 级错误中间件(commit `727b2c0`) +- **404 未匹配**:各框架默认行为,erest 不干预 +- **校验失败**:统一走 `compileValidate` 抛 ERestError 后 re-throw +- **reply.send 写入**:三框架 adapter 已封装(`res.end()` / `ctx.body=` / `ctx.response.send()`) + +### 2.3 可选统一错误格式器 + +erest 核心**新增**一个可选的错误响应格式化器,用户 opt-in 后三框架错误响应体格式一致。不强制、不改现有 examples、不触碰 adapter。 + +```ts +// src/lib/error.ts 新增 +export interface ErrorFormatter { + (err: ERestError | Error): { status: number; body: unknown }; +} + +/** 内置默认格式器(与现有 examples 一致),可直接用或自定义 */ +export const defaultErrorFormatter: ErrorFormatter = (err) => { + const e = err as ERestError; + const status = e.statusCode || (err as { status?: number }).status || 400; + return { + status, + body: { error: err.message, code: e.code ?? "INTERNAL_ERROR" }, + }; +}; +``` + +**接入方式:工具函数(不改 adapter)**。三框架一行替换,与刚对齐的 re-throw 链路零冲突: + +```ts +// Express +app.use((err, _req, res, _next) => { + const { status, body } = defaultErrorFormatter(err); + res.status(status).json(body); +}); + +// Koa +app.use(async (ctx, next) => { + try { await next(); } + catch (err) { + const { status, body } = defaultErrorFormatter(err); + ctx.status = status; ctx.body = body; + } +}); +``` + +> 不采用「adapter 内置格式化」方案——那会破坏 commit `727b2c0` 的 re-throw 对齐,且让 adapter 承担响应职责,违背「adapter 只做桥接」原则。 + +--- + +## §3 — 生命周期与 raw 的一致性 + +### 3.1 生命周期 hook 不暴露 raw + +| hook | 入参 | 拿 raw? | 说明 | +|------|------|---------|------| +| `onRequest` | `Context` | ❌ | 请求进入,handler 执行前 | +| `onValidate` | `Context` | ❌ | Zod 校验后 | +| `onError` | `Context + err` | ❌ | 错误发生时(仅观察,错误已 re-throw) | +| `onResponse` | `Context` | ❌ | 响应前 | + +理由:onError 场景下错误已 re-throw 给 app 级中间件,若 onError 能通过 raw 写响应,将与 re-throw 语义冲突(双重写入风险)。保持 hook 为纯观察者,符合 AGENTS.md 约定 4(hook 是观察者、不参与控制流)。 + +### 3.2 raw 与 reply 方法的写入顺序约束 + +raw 操作(cookie/header)写响应头区,`reply.json()` 写响应体。HTTP 协议下「头先于体发送」,故: + +- ✅ `reply.raw.res.cookie(...)` 然后 `reply.json(...)` —— 安全 +- ⚠️ `reply.json(...)` 然后 `reply.raw.res.cookie(...)` —— **可能失败**(body 写入后再设 header 会被忽略/警告) + +这是 raw 逃生舱的固有约束(任何 escape hatch 都有),文档明确标注:**「raw 的头部/cookie 操作应在 reply.json()/send() 之前调用」**。不代码强制。 + +### 3.3 Context 不加 raw 的一致性收益 + +before/middleware/hook 三处保持纯框架无关签名,可跨框架复用。现有 `examples/src/hooks.js` 的 authBefore/logMiddleware 等**零改动**。raw 是 handler 专属的「逃生舱」,职责清晰:标准链负责跨框架复用,raw 负责框架特有能力。 + +--- + +## §4 — 测试策略 + +| 层面 | 测试 | +|------|------| +| **类型层** | `reply.raw` 在 `createERest()` 后自动推导为正确类型;`new ERest()` 时为 unknown | +| **运行时(三框架)** | 各跑一个 setCookie handler,断言响应 `Set-Cookie` 头 | +| **错误格式器** | `defaultErrorFormatter` 对 ERestError / 普通 Error 输出一致 `{status, body}` 结构 | +| **向后兼容** | 现有 examples(裸 `new ERest` + 无 raw handler)零改动通过 | +| **生命周期** | onError hook 不因 raw 引入而产生双重写入 | +| **文档生成** | 带 raw 的 registerTyped 仍能正常生成 OpenAPI/Markdown(schema 还在) | + +--- + +## §5 — 决策记录 + +| 决策点 | 结论 | 理由 | +|--------|------|------| +| 范围 | raw 逃生舱 + 能力对齐审计 | setCookie 等底层能力的根本缺口 | +| raw 形态 | 复用 builder 链,`reply.raw` 透传 | 不另起平行 API,最小侵入 | +| raw 与 schema | 保留 Zod 校验 + 文档生成 | erest 核心价值不能丢 | +| middleware 触点 | 仅 handler 拿 raw | before/middleware/hook 保持跨框架复用 | +| raw 类型 | 框架泛型强类型,全自动推导 | 类型安全最好,体验最佳 | +| 泛型路径 | `ERest` 双泛型,构造时锁定 | TS 硬约束:运行时无法收紧类泛型 | +| 工厂位置 | 子包 `createERest()` | 构造时锁定 Raw 的唯一落点 | +| 兼容策略 | 废弃裸 `new ERest()`(过渡) | 推动升级,保留过渡期 | +| 原生能力对齐 | 仅 raw + 文档标注 | 不补 Reply 方法,尊重框架原生习惯 | +| 错误格式器 | 工具函数 `defaultErrorFormatter` | 不改 adapter,不破坏 re-throw 对齐 | +| 生命周期 | hook 不暴露 raw | 保持纯观察者,避免双重写入 | + +## §6 — 不在本次范围(YAGNI) + +- ❌ 给 Reply 补 cookie()/redirect()/setHeader() 等方法(三框架签名永远对不齐) +- ❌ 在 raw 之上抽象「统一原生能力层」(工作量大且与原生习惯冲突) +- ❌ 让 adapter 内置错误格式化(破坏 re-throw 对齐) +- ❌ 重新设计 Context/Reply 抽象边界(本次只加 raw 字段,不动既有结构) From 186eba472954b6ccc3f604dade56c6e125542f8f Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 29 Jun 2026 11:39:12 +0800 Subject: [PATCH 03/14] =?UTF-8?q?docs(plan):=20raw=20=E9=80=83=E7=94=9F?= =?UTF-8?q?=E8=88=B1=20+=20=E6=A1=86=E6=9E=B6=E8=83=BD=E5=8A=9B=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 个 TDD 任务:Reply 泛型(1) → API 透传(2) → ERest/IGroup 透传(3) → 错误格式器(4) → Express/Koa/leizmweb adapter(5/6/7) → 类型测试+文档+deprecated(8) --- ...w-escape-hatch-and-capability-alignment.md | 1118 +++++++++++++++++ 1 file changed, 1118 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-raw-escape-hatch-and-capability-alignment.md diff --git a/docs/superpowers/plans/2026-06-29-raw-escape-hatch-and-capability-alignment.md b/docs/superpowers/plans/2026-06-29-raw-escape-hatch-and-capability-alignment.md new file mode 100644 index 0000000..9c80a20 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-raw-escape-hatch-and-capability-alignment.md @@ -0,0 +1,1118 @@ +# raw 逃生舱(reply.raw)+ 框架能力对齐 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 给 registerTyped handler 增加 `reply.raw` 逃生舱,通过框架泛型在构造时锁定原生对象类型;并提供可选统一错误格式器。 + +**Architecture:** `Reply` 接口加泛型 `Reply` 与 `raw` 字段;`ERest` / `API` / `IGroup` 透传 Raw 维度;三框架 adapter 在 `createXxxReply` 时把原生对象塞进 `raw`(热路径零分配,仅多挂一个已有闭包变量的引用);子包新增 `createERest()` 工厂在构造时锁定 Raw。 + +**Tech Stack:** TypeScript 5、Zod 4、vitest、Express 5 / Koa 3 / @leizm/web 2 + +**关联 spec:** `docs/superpowers/specs/2026-06-29-raw-escape-hatch-and-capability-alignment.md` + +--- + +## 文件结构 + +**核心库(src/lib/):** +- `adapters/types.ts` — 修改:`Reply` 加 raw 字段;`FrameworkAdapter` 加 Raw 维度;`Context.reply` 类型标注 +- `error.ts` — 修改:新增 `ErrorFormatter` 接口与 `defaultErrorFormatter` 导出 +- `api.ts` — 修改:`API` → `API`;`registerTyped` handler reply 类型用 `Reply` +- `index.ts` — 修改:`ERest` → `ERest`;`IGroup` → `IGroup`;`group()` 透传 Raw + +**子包(packages/erest-*/src/index.ts):** 三个均修改 +- 导出 `XxxRaw` 类型别名 +- `createXxxReply` 塞 `raw` 值 +- 新增并导出 `createERest()` 工厂 + +**测试(src/test/):** +- `test-raw.ts` — 新建:raw 运行时集成测试(三框架 setCookie) +- `test-builder-types.ts` — 修改:增加 reply.raw 类型推导测试 + +--- + +## Task 1: Reply 接口加泛型与 raw 字段 + +**Files:** +- Modify: `src/lib/adapters/types.ts:60-79`(Reply 接口)、`:25-58`(FrameworkAdapter 接口)、`:90-124`(Context 接口的 reply 字段) + +- [ ] **Step 1: 修改 Reply 接口,加 Raw 泛型与 raw 字段** + +将 `src/lib/adapters/types.ts` 中 Reply 接口(72-79 行)替换为: + +```ts +/** + * 框架无关的响应接口。 + * + * 由各 adapter 注入到请求对象(`$reply`),让 registerTyped 的 handler 用统一的 + * `reply.json()/status()` 写响应,从而与具体框架解耦——同一份 handler 可被 + * Express / Koa / @leizm/web 三个框架复用。 + * + * `raw` 是逃生舱:当需要框架特有能力(setCookie/redirect/stream/文件下载等)时, + * 通过 `reply.raw` 访问框架原生对象。类型由 `ERest` 的 Raw 泛型驱动, + * 经子包 `createERest()` 工厂在构造时锁定。 + */ +export interface Reply { + /** 设置 HTTP 状态码并返回自身,支持链式调用 */ + status(code: number): this; + /** 以 JSON 写入响应体 */ + json(body: unknown): void; + /** 以纯文本写入响应体 */ + send(body: string): void; + /** 框架原生对象逃生舱(setCookie/redirect/stream/文件下载等) */ + readonly raw: Raw; +} +``` + +- [ ] **Step 2: FrameworkAdapter 接口加 Raw 泛型维度** + +将 `FrameworkAdapter`(25-58 行)的签名与方法内的 `T` 引用保留,仅给接口加第二个泛型参数。把第 25 行: + +```ts +export interface FrameworkAdapter { +``` + +改为: + +```ts +export interface FrameworkAdapter { +``` + +接口体内其它方法签名(`makeParamsChecker`/`bindRoute` 等)**不改**——它们只依赖 `T`,Raw 由 adapter 实现类通过 `implements FrameworkAdapter` 声明,不进方法签名。 + +- [ ] **Step 3: Context.reply 类型标注为 Reply** + +将 Context 接口中(105-106 行): + +```ts + /** 框架无关响应接口(复用 Reply;中间件可提前响应/终止) */ + readonly reply: Reply; +``` + +改为: + +```ts + /** 框架无关响应接口(复用 Reply;中间件可提前响应/终止) */ + readonly reply: Reply; +``` + +> Context 是框架无关的核心抽象,before/middleware/hook 不暴露 raw(spec §3.1)。reply 字段类型为 `Reply`,仅 registerTyped 的 handler 通过类泛型 Raw 拿到强类型 reply。 + +- [ ] **Step 4: 运行类型检查,确认无破坏** + +Run: `npx tsc --noEmit` +Expected: 编译通过(reply.raw 此时还是 unknown,三框架 createReply 还没塞值,但接口已含 raw 字段,已构造的 reply 对象字面量缺 raw 会报错——这些在 Task 4 修复;若此处报错,先确认错误都来自三框架的 createXxxReply 函数,属于预期) + +> 若 tsc 报 `Property 'raw' is missing in type ...` 指向 `packages/erest-*/src/index.ts` 的 createXxxReply,这是预期的——Task 4 会修复。把这类错误记录下来,确保 Task 4 全部覆盖。 + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/adapters/types.ts +git commit -m "feat(types): Reply 加 Raw 泛型与 raw 逃生舱字段 + +- Reply 接口新增 readonly raw 字段 +- FrameworkAdapter 加 Raw 维度(方法签名不变) +- Context.reply 标注 Reply(before/middleware 不暴露 raw)" +``` + +--- + +## Task 2: API 类透传 Raw 泛型 + +**Files:** +- Modify: `src/lib/api.ts:9`(import)、`:72`(class API)、`:316-338`(registerTyped handler 类型) + +- [ ] **Step 1: import 处补 Reply 的泛型用法说明(无需改 import)** + +`src/lib/api.ts:9` 已是 `import type { Middleware, Reply } from "./adapters/types.js";`,Reply 现在是泛型,import 不变。无需改动此行。 + +- [ ] **Step 2: API 类加 Raw 泛型维度** + +将 `src/lib/api.ts:72`: + +```ts +class API { +``` + +改为: + +```ts +class API { +``` + +- [ ] **Step 3: registerTyped handler 的 reply 类型用 Reply** + +将 `src/lib/api.ts:330-338` 的 handler 参数类型中的 `reply: Reply`: + +```ts + handler: ( + req: { + query: z.infer>; + body: z.infer>; + params: z.infer>; + headers: z.infer>; + }, + reply: Reply + ) => z.infer | Promise> | void | Promise +``` + +改为(仅 `reply: Reply` → `reply: Reply`): + +```ts + handler: ( + req: { + query: z.infer>; + body: z.infer>; + params: z.infer>; + headers: z.infer>; + }, + reply: Reply + ) => z.infer | Promise> | void | Promise +``` + +> 关键:`registerTyped` 现有的 `TQuery/TBody/TParams/THeaders/TResponse` 5 个方法级泛型**完全不变**。Raw 是类级泛型,从 `API` 透传到 handler 的 reply 类型,两者正交。 + +- [ ] **Step 4: 运行类型检查** + +Run: `npx tsc --noEmit` +Expected: 编译通过(API 类的其它方法如 `init(parent: ERest)` 不涉及 Raw,无需改;registerTyped 内部 `wrappedHandler` 用的是 `ctx.reply` 即 `Reply`,运行时无影响) + +- [ ] **Step 5: 运行现有测试确认无回归** + +Run: `npm run test:lib` +Expected: 全部通过(reply.raw 在运行时此时还是 undefined,但现有测试不访问 raw,不受影响) + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/api.ts +git commit -m "feat(api): API 透传 Raw,registerTyped reply 类型用 Reply + +类级加 Raw 泛型维度(默认 unknown),透传到 registerTyped handler 的 reply。 +现有 5 个方法级 Zod 泛型不变,Raw 与之正交。" +``` + +--- + +## Task 3: ERest 类与 IGroup 透传 Raw 泛型 + +**Files:** +- Modify: `src/lib/index.ts:36-43`(IGroup)、`:46-54`(IApiInfo)、`:124`(class ERest)、`:315-334`(registAPI)、`:463-485`(group) + +- [ ] **Step 1: IGroup 接口加 Raw 维度** + +将 `src/lib/index.ts:39-43`: + +```ts +export interface IGroup extends Record, genSchema { + define: (opt: APIDefine) => API; + before: (...fn: T[]) => IGroup; + middleware: (...fn: T[]) => IGroup; +} +``` + +改为: + +```ts +export interface IGroup extends Record, genSchema { + define: (opt: APIDefine) => API; + before: (...fn: T[]) => IGroup; + middleware: (...fn: T[]) => IGroup; +} +``` + +> `genSchema` 返回 `(path) => API`,API 现在是 `API`。需要同步更新 `genSchema` 定义(`:36`)。 + +- [ ] **Step 2: genSchema 类型加 Raw 维度** + +将 `src/lib/index.ts:36`: + +```ts +export type genSchema = Readonly API>>; +``` + +改为: + +```ts +export type genSchema = Readonly API>>; +``` + +- [ ] **Step 3: IApiInfo 接口加 Raw 维度** + +将 `src/lib/index.ts:46-54`: + +```ts +export interface IApiInfo extends Record, genSchema { + readonly $apis: Map>; + define: (opt: APIDefine) => API; + beforeHooks: Set; + afterHooks: Set; + docs?: IAPIDoc; + formatOutputReverse?: (out: unknown) => [Error | null, unknown]; + docOutputFormat?: (out: unknown) => unknown; +} +``` + +改为: + +```ts +export interface IApiInfo extends Record, genSchema { + readonly $apis: Map>; + define: (opt: APIDefine) => API; + beforeHooks: Set; + afterHooks: Set; + docs?: IAPIDoc; + formatOutputReverse?: (out: unknown) => [Error | null, unknown]; + docOutputFormat?: (out: unknown) => unknown; +} +``` + +- [ ] **Step 4: ERest 类加 Raw 维度** + +将 `src/lib/index.ts:124`: + +```ts +class ERest { +``` + +改为: + +```ts +class ERest { +``` + +- [ ] **Step 5: registAPI 内 new API 加 Raw 透传** + +将 `src/lib/index.ts:322`: + +```ts + const s = new API(method, path, getCallerSourceLine(this.config.path), group, prefix); +``` + +改为: + +```ts + const s = new API(method, path, getCallerSourceLine(this.config.path), group, prefix); +``` + +- [ ] **Step 6: group() 方法返回类型加 Raw** + +将 `src/lib/index.ts:463-465`(group 方法的两个重载签名 + 实现): + +```ts + public group(name: string, info?: IGroupInfoOpt): IGroup; + public group(name: string, desc?: string): IGroup; + public group(name: string, infoOrDesc?: IGroupInfoOpt | string): IGroup { +``` + +改为: + +```ts + public group(name: string, info?: IGroupInfoOpt): IGroup; + public group(name: string, desc?: string): IGroup; + public group(name: string, infoOrDesc?: IGroupInfoOpt | string): IGroup { +``` + +> 实现体内 `this.apiInfo` 的 `get/post/...` 方法返回的是 `API`(无 Raw),但 IGroup 要求 `API`。由于 `API` 等价于 `API`,且 group 返回的 IGroup 其 get/post 来自 `genSchema`——这里 apiInfo 的方法(`this.apiInfo.get`)返回 `API`,需确认类型兼容。见下一步处理 apiInfo。 + +- [ ] **Step 7: apiInfo 字段类型用 Raw,构造时 get/post 等 new API 透传 Raw** + +apiInfo 的类型声明(找到 `private apiInfo: IApiInfo;`,约 `:143`),改为: + +```ts + private apiInfo: IApiInfo; +``` + +构造函数中 apiInfo 初始化(`:351-361`)的 `get/post/...` 回调里调用 `this.registAPI(...)` 返回 `API`,已匹配。`define` 回调返回 `this.defineAPI(...)` 返回 `API`,已匹配。**无需改构造体内容**,只改类型声明。 + +- [ ] **Step 8: group() 实现体返回的 groupInfo 对象类型** + +group() 实现体内(`:470` 附近)构造并返回一个 IGroup 对象字面量,其 `get/post/...` 引用 `this.apiInfo.get` 等。由于 apiInfo 现在是 `IApiInfo`,其方法返回 `API`,与 IGroup 要求一致。 + +检查返回对象字面量(`:475-490` 附近,含 `define`/`get`/`post`/`before`/`middleware`)。若 `before`/`middleware` 实现返回 `this` 或重建 IGroup,需确保返回类型为 `IGroup`。运行 tsc 定位具体报错点,按报错补类型参数。 + +- [ ] **Step 9: 运行类型检查并修复剩余报错** + +Run: `npx tsc --noEmit` +Expected: 编译通过。若 group() 实现体或其它处报 Raw 缺失,按报错信息补 `` 类型参数。 + +- [ ] **Step 10: 运行现有测试确认无回归** + +Run: `npm run test:lib` +Expected: 全部通过 + +- [ ] **Step 11: Commit** + +```bash +git add src/lib/index.ts +git commit -m "feat(erest): ERest / IGroup 透传 Raw 泛型 + +- ERest/API/IGroup/IApiInfo/genSchema 全链路加 Raw 维度(默认 unknown) +- group() 返回 IGroup,registAPI 透传 Raw +- 现有 new ERest() 行为不变(Raw 默认 unknown)" +``` + +--- + +## Task 4: 错误格式器(defaultErrorFormatter) + +**Files:** +- Modify: `src/lib/error.ts`(末尾追加) +- Test: `src/test/test-error-formatter.ts`(新建) + +- [ ] **Step 1: 新建测试文件 test-error-formatter.ts,写失败测试** + +创建 `src/test/test-error-formatter.ts`: + +```ts +/** + * @file defaultErrorFormatter 测试 + * 验证错误格式器对 ERestError / 普通 Error 输出一致的 {status, body} 结构 + */ +import { describe, expect, it } from "vitest"; +import { ERestError, defaultErrorFormatter } from "../lib/error.js"; + +describe("defaultErrorFormatter", () => { + it("ERestError 输出 statusCode 与 code/message body", () => { + const err = new ERestError("AUTH_REQUIRED", "未登录", undefined, 401); + const out = defaultErrorFormatter(err); + expect(out.status).toBe(401); + expect(out.body).toEqual({ error: "未登录", code: "AUTH_REQUIRED" }); + }); + + it("ERestError 默认 statusCode 400", () => { + const err = new ERestError("VALIDATION_ERROR", "校验失败"); + const out = defaultErrorFormatter(err); + expect(out.status).toBe(400); + expect(out.body).toEqual({ error: "校验失败", code: "VALIDATION_ERROR" }); + }); + + it("普通 Error 退化为 400 + INTERNAL_ERROR", () => { + const err = new Error("boom"); + const out = defaultErrorFormatter(err); + expect(out.status).toBe(400); + expect(out.body).toEqual({ error: "boom", code: "INTERNAL_ERROR" }); + }); + + it("带 status 属性的 Error 取 status", () => { + const err = Object.assign(new Error("forbidden"), { status: 403 }); + const out = defaultErrorFormatter(err); + expect(out.status).toBe(403); + expect(out.body).toEqual({ error: "forbidden", code: "INTERNAL_ERROR" }); + }); +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `npm run test:lib -- test-error-formatter` +Expected: FAIL,报 `defaultErrorFormatter is not exported` 或 `does not provide an export named 'defaultErrorFormatter'` + +- [ ] **Step 3: 在 error.ts 末尾实现 ErrorFormatter 与 defaultErrorFormatter** + +在 `src/lib/error.ts` 文件**末尾**追加: + +```ts +/** + * 错误响应格式器:把任意错误归一化为 { status, body } 结构。 + * + * 用户在 app 级错误中间件中调用(三框架一行替换),实现跨框架统一的错误响应体。 + * 不由 adapter 内置——adapter 只做桥接,错误响应格式由用户在 app 级决定。 + * + * @example + * // Express + * app.use((err, _req, res, _next) => { + * const { status, body } = defaultErrorFormatter(err); + * res.status(status).json(body); + * }); + */ +export interface ErrorFormatter { + (err: ERestError | Error): { status: number; body: unknown }; +} + +/** 内置默认格式器(与 examples 错误中间件一致),可直接用或自定义 */ +export const defaultErrorFormatter: ErrorFormatter = (err) => { + const e = err as ERestError & { status?: number }; + const status = e.statusCode || e.status || 400; + return { + status, + body: { + error: err.message, + code: e.code ?? "INTERNAL_ERROR", + }, + }; +}; +``` + +> 注意 `e.code ?? "INTERNAL_ERROR"`:普通 Error 没有 code 属性,`e.code` 为 undefined,用 nullish 合并退化为 "INTERNAL_ERROR"。ERestError 的 code 是 `ERestErrorCode` 字符串字面量,正常透传。 + +- [ ] **Step 4: 运行测试确认通过** + +Run: `npm run test:lib -- test-error-formatter` +Expected: 4 个用例全部 PASS + +- [ ] **Step 5: 确认 defaultErrorFormatter 从核心包导出** + +检查 `src/lib/index.ts` 末尾的 re-export。确认 `error.ts` 的导出是否已被 re-export(搜索 `export.*from.*error`)。若没有,在 index.ts 的 re-export 区(ERestError 等导出处)追加: + +```ts +export { defaultErrorFormatter, type ErrorFormatter } from "./error.js"; +``` + +> 先搜索确认现有 ERestError 是如何导出的——若 index.ts 用 `export * from "./error.js"` 或具名导出,按相同方式补 defaultErrorFormatter。 + +Run: `npx tsc --noEmit` 确认无报错。 + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/error.ts src/test/test-error-formatter.ts src/lib/index.ts +git commit -m "feat(error): 新增可选 defaultErrorFormatter 错误格式器 + +工具函数,用户在 app 级错误中间件调用,实现三框架统一错误响应体。 +不改 adapter、不破坏 re-throw 对齐(commit 727b2c0)。" +``` + +--- + +## Task 5: Express adapter 塞 raw + 导出 ExpressRaw + createERest 工厂 + +**Files:** +- Modify: `packages/erest-express/src/index.ts`(import、createExpressReply、导出) +- Test: `src/test/test-raw.ts`(新建,本 task 先写 Express 部分) + +- [ ] **Step 1: 新建测试 test-raw.ts,写 Express setCookie 失败测试** + +创建 `src/test/test-raw.ts`: + +```ts +/** + * @file reply.raw 逃生舱集成测试 + * 验证三框架 handler 通过 reply.raw 访问原生对象(以 setCookie 为例) + */ +import express from "express"; +import Koa from "koa"; +import bodyParser from "koa-bodyparser"; +import KoaRouter from "koa-router"; +import { Application, component, Router } from "@leizm/web"; +import { expressAdapter, koaAdapter, leizmwebAdapter } from "./adapters"; +import { httpReq as request } from "./http-req"; +import { afterAll, describe, expect, it } from "vitest"; +import { z } from "zod"; +import lib from "./lib"; + +// ---------------- Express ---------------- +describe("reply.raw - Express 集成", () => { + const app = express(); + app.use(express.json()); + + const apiService = lib({ basePath: "" }); + apiService.api + .post("/login") + .group("Index") + .title("login-express") + .registerTyped({ body: z.object({ user: z.string() }) }, (_req, reply) => { + // reply.raw 在 Express 下为 { req, res },通过 res.cookie 设置 cookie + const res = (reply as any).raw.res; + res.cookie("token", "abc-123", { httpOnly: true }); + reply.json({ ok: true }); + }); + + apiService.bind({ adapter: expressAdapter, router: app }); + + it("handler 能通过 reply.raw.res.cookie 设置 Set-Cookie 响应头", async () => { + const res = await request(app).post("/login").send({ user: "Tom" }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + const setCookie = res.headers["set-cookie"]; + expect(setCookie).toBeDefined(); + expect(String(setCookie)).toContain("token=abc-123"); + expect(String(setCookie)).toContain("HttpOnly"); + }); +}); + +afterAll(() => {}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `npm run test:lib -- test-raw` +Expected: FAIL,`res.headers["set-cookie"]` 为 undefined(reply.raw 此时没被赋值,`res` 是 undefined,cookie 没设置) + +- [ ] **Step 3: Express adapter 导出 ExpressRaw 类型** + +在 `packages/erest-express/src/index.ts` 顶部 import 区,确认已 import express 类型。若没有,添加: + +```ts +import type { Request, Response } from "express"; +``` + +在文件中(class ExpressAdapter 定义之前或之后)添加类型别名与 Reply 导入。先确认顶部 import 行(`:9`): + +```ts +import type { Context, FrameworkAdapter, Middleware, Reply } from "erest"; +``` + +保持不变(Reply 已导入)。在 class 定义前添加: + +```ts +/** Express 原生对象类型(reply.raw 在 Express 下为此类型) */ +export type ExpressRaw = { req: Request; res: Response }; +``` + +- [ ] **Step 4: ExpressAdapter 类实现加 Raw 维度** + +将 class 声明(约 `:19`): + +```ts +export class ExpressAdapter implements FrameworkAdapter { +``` + +改为: + +```ts +export class ExpressAdapter implements FrameworkAdapter { +``` + +- [ ] **Step 5: createExpressReply 塞 raw 值** + +找到 `createExpressReply` 函数(约 `:133-149`)。当前签名只接收 `res`: + +```ts +function createExpressReply(res: unknown): Reply { + const expressRes = res as { status?: ...; json?: ...; end?: ... }; + const reply: Reply = { + status(code: number) { + expressRes.status?.(code); + return reply; + }, + json(body: unknown) { + expressRes.json?.(body); + }, + // ... send + }; + return reply; +} +``` + +改为接收 `req` 和 `res`,返回 `Reply`,并在 reply 对象上挂 raw: + +```ts +function createExpressReply(req: unknown, res: unknown): Reply { + const expressRes = res as { status?: (c: number) => unknown; json?: (b: unknown) => void; end?: (b: string) => void }; + const reply: Reply = { + status(code: number) { + expressRes.status?.(code); + return reply; + }, + json(body: unknown) { + expressRes.json?.(body); + }, + // send 保持原样(从原函数复制,不要遗漏) + raw: { req, res } as ExpressRaw, + }; + return reply; +} +``` + +> **重要**:`send` 方法的实现从原函数完整复制过来,不要丢失。先 Read 原函数确认 send 的确切实现再替换。 + +- [ ] **Step 6: nativeMiddleware 调用处传 req** + +找到 `bindRoute` 内的 `nativeMiddleware`(约 `:73-89`),其中调用 `createExpressReply(res)`(约 `:74`): + +```ts + const reply: Reply = createExpressReply(res); +``` + +改为: + +```ts + const reply = createExpressReply(req, res); +``` + +> `req` 是 nativeMiddleware 的入参(`:73` 的 `req`),闭包本就持有,热路径零分配——仅多挂一个已有引用。 + +- [ ] **Step 7: 新增 createERest 工厂并导出** + +子包是 ESM(package.json `"type": "module"`),必须用静态 import。先在文件顶部 import 区(`:9` 行 `import type { Context, FrameworkAdapter, Middleware, Reply } from "erest";` 之后)添加 ERest 值导入: + +```ts +import { ERest } from "erest"; +``` + +然后在文件末尾(`export const expressAdapter` 之后)添加工厂: + +```ts +/** + * 创建绑定 Express 原生类型的 ERest 实例(构造时锁定 Raw 泛型)。 + * + * 返回的 ERest 实例中,registerTyped handler 的 reply.raw 自动推导为 ExpressRaw, + * 无需手动标注。等价于 `new ERest(options)`。 + * + * @example + * import { createERest } from "@erest/express"; + * const api = createERest({ info, groups, forceGroup }); + */ +export function createERest(options: ConstructorParameters[0]): ERest { + return new ERest(options); +} +``` + +> `Middleware` 类型已在顶部 import 行导入(与 Reply 同行)。 + +- [ ] **Step 8: 运行测试确认通过** + +Run: `npm run test:lib -- test-raw` +Expected: Express 集成用例 PASS(Set-Cookie 头包含 token=abc-123; HttpOnly) + +- [ ] **Step 9: 类型检查** + +Run: `npx tsc --noEmit` +Expected: 通过 + +- [ ] **Step 10: Commit** + +```bash +git add packages/erest-express/src/index.ts src/test/test-raw.ts +git commit -m "feat(express): reply.raw 塞原生对象 + 导出 ExpressRaw + createERest 工厂 + +- createExpressReply 接收 req/res,reply.raw = { req, res } +- 导出 ExpressRaw 类型别名 +- ExpressAdapter implements FrameworkAdapter +- 新增 createERest() 工厂构造时锁定 Raw 泛型" +``` + +--- + +## Task 6: Koa adapter 塞 raw + 导出 KoaRaw + createERest 工厂 + +**Files:** +- Modify: `packages/erest-koa/src/index.ts` + +- [ ] **Step 1: 在 test-raw.ts 追加 Koa setCookie 测试** + +在 `src/test/test-raw.ts` 的 Express describe 块之后、`afterAll` 之前追加: + +```ts +// ---------------- Koa ---------------- +describe("reply.raw - Koa 集成", () => { + it("handler 能通过 reply.raw.cookies.set 设置 Set-Cookie", async () => { + const app = new Koa(); + app.use(bodyParser()); + app.use(async (ctx, next) => { + try { + await next(); + } catch (err: unknown) { + ctx.status = (err as { statusCode?: number }).statusCode || 400; + ctx.body = { message: (err as Error).message }; + } + }); + + const apiService = lib({ basePath: "" }); + const router = new KoaRouter(); + apiService.api + .post("/login") + .group("Index") + .title("login-koa") + .registerTyped({ body: z.object({ user: z.string() }) }, (_req, reply) => { + // reply.raw 在 Koa 下为原生 ctx + const ctx = (reply as any).raw; + ctx.cookies.set("token", "koa-456", { httpOnly: true }); + reply.json({ ok: true }); + }); + apiService.bind({ adapter: koaAdapter, router }); + app.use(router.routes()).use(router.allowedMethods()); + + const server = app.listen(); + const res = await request(server).post("/login").send({ user: "Jerry" }); + server.close(); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + const setCookie = res.headers["set-cookie"]; + expect(setCookie).toBeDefined(); + expect(String(setCookie)).toContain("token=koa-456"); + expect(String(setCookie)).toContain("HttpOnly"); + }); +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `npm run test:lib -- test-raw` +Expected: Koa 用例 FAIL(reply.raw 未赋值,ctx.cookies 是 undefined) + +- [ ] **Step 3: Koa adapter 导出 KoaRaw 类型 + import Koa Context 类型** + +在 `packages/erest-koa/src/index.ts` 顶部 import 区,确认/添加 koa 类型导入: + +```ts +import type { Context as KoaContext } from "koa"; +``` + +在 class 定义前添加: + +```ts +/** Koa 原生对象类型(reply.raw 在 Koa 下为原生 Context) */ +export type KoaRaw = KoaContext; +``` + +- [ ] **Step 4: KoaAdapter 类加 Raw 维度** + +将 class 声明改为: + +```ts +export class KoaAdapter implements FrameworkAdapter { +``` + +- [ ] **Step 5: createKoaReply 塞 raw 值** + +找到 `createKoaReply`(约 `:133-150`)。当前接收 `ctx`,返回 `Reply`。改为返回 `Reply` 并挂 raw: + +```ts +function createKoaReply(ctx: Record): Reply { + const koaCtx = ctx as { status?: number; body?: unknown; type?: string }; + const reply: Reply = { + status(code: number) { + koaCtx.status = code; + return reply; + }, + json(body: unknown) { + koaCtx.type = "application/json"; + // ... 保持原 json 实现(从原函数复制完整) + }, + // send 保持原样 + raw: ctx as KoaRaw, + }; + return reply; +} +``` + +> **重要**:json 与 send 的实现体从原函数完整复制,不要遗漏任何行。先 Read 原函数。 + +- [ ] **Step 6: 新增 createERest 工厂** + +顶部 import 区添加(确认 `ERest`、`Middleware` 已从 erest 导入): + +```ts +import { ERest, type Middleware } from "erest"; +``` + +文件末尾追加: + +```ts +/** + * 创建绑定 Koa 原生类型的 ERest 实例(构造时锁定 Raw 泛型)。 + * handler 的 reply.raw 自动推导为 KoaRaw(原生 Context)。 + */ +export function createERest(options: ConstructorParameters[0]): ERest { + return new ERest(options); +} +``` + +- [ ] **Step 7: 运行测试确认通过** + +Run: `npm run test:lib -- test-raw` +Expected: Koa 用例 PASS + +- [ ] **Step 8: 类型检查** + +Run: `npx tsc --noEmit` +Expected: 通过 + +- [ ] **Step 9: Commit** + +```bash +git add packages/erest-koa/src/index.ts src/test/test-raw.ts +git commit -m "feat(koa): reply.raw 塞原生 ctx + 导出 KoaRaw + createERest 工厂" +``` + +--- + +## Task 7: leizmweb adapter 塞 raw + 导出 LeizmWebRaw + createERest 工厂 + +**Files:** +- Modify: `packages/erest-leizmweb/src/index.ts` + +- [ ] **Step 1: 在 test-raw.ts 追加 leizmweb setCookie 测试** + +在 `src/test/test-raw.ts` 的 Koa describe 块之后追加: + +```ts +// ---------------- @leizm/web ---------------- +describe("reply.raw - @leizm/web 集成", () => { + it("handler 能通过 reply.raw.response 设置响应(raw 逃生舱可用)", async () => { + const app = new Application(); + app.use("/", component.bodyParser.json()); + + const apiService = lib({ basePath: "" }); + const router = new Router(); + apiService.api + .post("/login") + .group("Index") + .title("login-lei") + .registerTyped({ body: z.object({ user: z.string() }) }, (_req, reply) => { + // reply.raw 在 @leizm/web 下为原生 ctx;验证 raw 逃生舱可访问原生对象 + const raw = (reply as any).raw; + // 设置自定义响应头作为 raw 可用性的证明(leizmweb cookie API 随版本变化,用 header 更稳) + raw.response.set("X-Raw-Test", "leizmweb"); + reply.json({ ok: true }); + }); + apiService.bind({ adapter: leizmwebAdapter, router }); + app.use("/", router); + + const res = await request(app.server).post("/login").send({ user: "Anna" }); + app.server.close(); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + // 验证 raw 设置的自定义头生效 + expect(res.headers["x-raw-test"]).toBe("leizmweb"); + }); +}); +``` + +> leizmweb 的 cookie API 因版本差异签名不统一,改用设置自定义响应头 `X-Raw-Test` 作为「raw 逃生舱可用」的稳定证明(spec §2.1 速查表也用 header 类操作)。 + +- [ ] **Step 2: 运行测试确认失败** + +Run: `npm run test:lib -- test-raw` +Expected: leizmweb 用例 FAIL(reply.raw 未赋值,raw.response 是 undefined) + +- [ ] **Step 3: leizmweb adapter 导出 LeizmWebRaw 类型** + +在 `packages/erest-leizmweb/src/index.ts` 顶部 import 区确认/添加: + +```ts +import type { Context as LeiContext } from "@leizm/web"; +``` + +> 若 `@leizm/web` 未直接导出 Context 类型,用 `unknown` 兜底并注释说明,类型别名为 `LeiContext | unknown`。先尝试 import,失败则降级。 + +class 定义前添加: + +```ts +/** @leizm/web 原生对象类型(reply.raw 在 @leizm/web 下为原生 Context) */ +export type LeizmWebRaw = LeiContext; +``` + +- [ ] **Step 4: LeizmWebAdapter 类加 Raw 维度** + +将 class 声明改为: + +```ts +export class LeizmWebAdapter implements FrameworkAdapter { +``` + +- [ ] **Step 5: createLeiReply 塞 raw 值** + +找到 `createLeiReply`(约 `:131-151`)。当前接收 `ctx`,返回 `Reply`。改为返回 `Reply` 并挂 raw: + +```ts +function createLeiReply(ctx: Record): Reply { + const leiRes = (ctx.response ?? {}) as { + status?: (c: number) => unknown; + json?: (b: unknown) => void; + // send 保持原样 + }; + const reply: Reply = { + status(code: number) { + leiRes.status?.(code); + return reply; + }, + json(body: unknown) { + leiRes.json?.(body); + }, + // send 保持原样(从原函数复制) + raw: ctx as LeizmWebRaw, + }; + return reply; +} +``` + +> **重要**:完整复制原函数的 json/send 实现体,先 Read 原函数。 + +- [ ] **Step 6: 新增 createERest 工厂** + +顶部 import 区确认/添加: + +```ts +import { ERest, type Middleware } from "erest"; +``` + +文件末尾追加: + +```ts +/** + * 创建绑定 @leizm/web 原生类型的 ERest 实例(构造时锁定 Raw 泛型)。 + * handler 的 reply.raw 自动推导为 LeizmWebRaw(原生 Context)。 + */ +export function createERest(options: ConstructorParameters[0]): ERest { + return new ERest(options); +} +``` + +- [ ] **Step 7: 运行测试确认通过** + +Run: `npm run test:lib -- test-raw` +Expected: leizmweb 用例 PASS + +- [ ] **Step 8: 类型检查** + +Run: `npx tsc --noEmit` +Expected: 通过 + +- [ ] **Step 9: Commit** + +```bash +git add packages/erest-leizmweb/src/index.ts src/test/test-raw.ts +git commit -m "feat(leizmweb): reply.raw 塞原生 ctx + 导出 LeizmWebRaw + createERest 工厂" +``` + +--- + +## Task 8: 类型推导测试 + 全量回归 + 文档速查表 + +**Files:** +- Modify: `src/test/test-builder-types.ts`(追加 reply.raw 类型推导用例) +- Verify: 三框架 raw 测试 + 现有全量测试 +- Docs: README 或 docs 新增框架原生能力速查表(spec §2.1) + +- [ ] **Step 1: 在 test-builder-types.ts 追加 reply.raw 类型推导测试** + +在 `src/test/test-builder-types.ts` 的现有 describe 块内追加一个 test: + +```ts + test("new ERest() 时 reply.raw 为 unknown(默认 Raw)", () => { + apiService.api + .post("/raw-unknown") + .group("Index") + .registerTyped({ body: z.object({ x: z.number() }) }, (_req, reply) => { + // 默认 Raw=unknown:reply.raw 类型为 unknown,需断言才能用 + expectTypeOf(reply.raw).toEqualTypeOf(); + }); + }); +``` + +- [ ] **Step 2: 运行类型测试** + +Run: `npm run test:lib -- test-builder-types` +Expected: PASS(reply.raw 默认 unknown) + +- [ ] **Step 3: 新建子包类型推导测试 test-raw-types.ts(验证 createERest 锁定 Raw)** + +创建 `src/test/test-raw-types.ts`: + +```ts +/** + * @file reply.raw 框架泛型类型推导测试 + * 验证子包 createERest() 工厂构造时锁定 Raw,handler reply.raw 自动强类型 + */ +import express from "express"; +import { describe, test, expectTypeOf } from "vitest"; +import { ExpressAdapter, createERest as createExpressERest, type ExpressRaw } from "../../packages/erest-express/src/index.js"; + +describe("createERest 类型锁定", () => { + test("Express createERest 返回的实例 handler reply.raw 为 ExpressRaw", () => { + const api = createExpressERest({ + info: { title: "t", version: "1.0.0" }, + groups: { Index: "首页" }, + }); + api + .post("/raw-typed") + .group("Index") + .registerTyped({}, (_req, reply) => { + // 构造时锁定 Raw=ExpressRaw:reply.raw 自动强类型,零标注 + expectTypeOf(reply.raw).toEqualTypeOf(); + // reply.raw.res 是 Express Response + expectTypeOf(reply.raw.res).toMatchTypeOf(); + }); + }); +}); +``` + +- [ ] **Step 4: 运行类型测试** + +Run: `npm run test:lib -- test-raw-types` +Expected: PASS + +> 若 `expectTypeOf(reply.raw).toEqualTypeOf()` 失败(raw 可能含 undefined 或为 readonly 导致精确不等),改用 `.toMatchTypeOf()` 宽松匹配。 + +- [ ] **Step 5: 运行全量核心库测试(确认无回归)** + +Run: `npm run test:lib` +Expected: 全部通过(含原有的 test-register-typed、test-integration-* 等) + +- [ ] **Step 6: 运行 examples 集成测试(确认 examples 零改动通过)** + +Run: `npm run build && npm run build:packages && pnpm --filter erest-example test` +Expected: 全部通过(examples 仍用裸 `new ERest()`,Raw=unknown,但 examples 不访问 raw,零影响) + +> 若 examples 测试因构建产物变化失败,先 `npm run build:packages` 重建子包 dist。 + +- [ ] **Step 7: 新增框架原生能力速查表文档** + +在 README.md 的「框架适配器」章节(或 AGENTS.md「改 X 去 Y」表后)追加 spec §2.1 的速查表。定位 README 中 adapter 相关章节,追加: + +```markdown +### reply.raw 逃生舱与框架原生能力速查 + +`reply.raw` 暴露框架原生对象(setCookie/redirect/stream 等)。类型由 `createERest()` 工厂锁定: + +| 能力 | Express (`reply.raw`) | Koa (`reply.raw`) | @leizm/web (`reply.raw`) | +|------|------|------|------| +| 设置 cookie | `.res.cookie(name, val, opts)` | `.cookies.set(name, val, opts)` | `.response.cookie(...)` | +| 读取 cookie | `.req.cookies`(需 cookie-parser) | `.cookies.get(name)` | `.request.cookies` | +| 重定向 | `.res.redirect(code, url)` | `.redirect(url)` | `.response.redirect(...)` | +| 流式响应 | `.res.write()` / `.res.end()` | `.body = stream` | `.response.send()` | +| 响应头已发送 | `.res.headersSent` | `.res.headersSent` | 查原生 | + +> raw 的头部/cookie 操作应在 `reply.json()`/`reply.send()` 之前调用(HTTP 头先于体发送)。 +``` + +- [ ] **Step 8: 标记裸 new ERest() 为 deprecated(过渡期)** + +在 `src/lib/index.ts` 的 ERest 类构造函数上方(`:292` 之前)添加 JSDoc: + +```ts + /** + * @deprecated 推荐使用子包工厂 `createERest()`(如 `@erest/express` 的 createERest), + * 以在构造时锁定 Raw 泛型,让 handler 的 reply.raw 自动强类型。 + * 裸 new ERest() 在过渡期保留,Raw 默认 unknown,reply.raw 需手动断言。 + */ + constructor(options: IApiOption) { +``` + +Run: `npx tsc --noEmit` 确认无报错。 + +- [ ] **Step 9: 最终全量验证** + +Run: `npm test` +Expected: 全部通过(含 build、build:packages、vitest、examples) + +- [ ] **Step 10: Commit** + +```bash +git add src/test/test-builder-types.ts src/test/test-raw-types.ts src/lib/index.ts README.md +git commit -m "test+docs: reply.raw 类型推导测试、能力速查表、标记 new ERest() deprecated + +- 类型测试验证 createERest 锁定 Raw(零标注强类型) +- README 新增 reply.raw 框架原生能力速查表 +- 构造函数标记 @deprecated,引导到子包 createERest()" +``` + +--- + +## Self-Review(写完后自查,非执行步骤) + +完成后对照 spec 逐项确认: +- [ ] spec §1.1 Reply+raw 字段 → Task 1 +- [ ] spec §1.2 构造时锁定(TS 硬约束) → Task 5/6/7 createERest +- [ ] spec §1.3 泛型透传 6 处 → Task 1-3 +- [ ] spec §1.4 使用形态 → Task 8 类型测试验证 +- [ ] spec §1.5 三框架 adapter 改动 → Task 5/6/7 +- [ ] spec §1.6 兼容策略(废弃裸 new) → Task 8 Step 8 +- [ ] spec §2.1 原生能力速查表 → Task 8 Step 7 +- [ ] spec §2.3 defaultErrorFormatter → Task 4 +- [ ] spec §3.1 hook 不暴露 raw → Task 1 Step 3(Context.reply 为 Reply) +- [ ] spec §4 测试策略 → Task 5/6/7(运行时三框架)+ Task 8(类型层) From 383b3d981482a2fa2d933edf9b65feb068a4de66 Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 29 Jun 2026 11:58:47 +0800 Subject: [PATCH 04/14] =?UTF-8?q?feat(types):=20Reply=20=E5=8A=A0=20Raw=20?= =?UTF-8?q?=E6=B3=9B=E5=9E=8B=E4=B8=8E=20raw=20=E9=80=83=E7=94=9F=E8=88=B1?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reply 接口新增 readonly raw 字段,status 返回类型改为 this - FrameworkAdapter 加 Raw 维度(方法签名不变) - Context.reply 标注 Reply(before/middleware 不暴露 raw) tsc 全量通过:三框架子包 createXxxReply 因结构兼容未报错, Task 5/6/7 将在 createReply 时真正塞入 raw 值。 --- src/lib/adapters/types.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/lib/adapters/types.ts b/src/lib/adapters/types.ts index fef5f9c..dc1ded6 100644 --- a/src/lib/adapters/types.ts +++ b/src/lib/adapters/types.ts @@ -22,7 +22,7 @@ export interface IAdapterGroupInfo { * Provides unified interface for different web frameworks (Express/Koa/@leizm/web built-in via subpackages; * third-party adapters implement this interface with an arbitrary `name`) */ -export interface FrameworkAdapter { +export interface FrameworkAdapter { /** Framework identifier (e.g. "express"/"koa"/"leizmweb", or a custom adapter name) */ readonly name: string; @@ -68,14 +68,20 @@ export type CheckerFunction = (erest: ERest, schema: API) => T; * 由各 adapter 注入到请求对象(`$reply`),让 registerTyped 的 handler 用统一的 * `reply.json()/status()` 写响应,从而与具体框架解耦——同一份 handler 可被 * Express / Koa / @leizm/web 三个框架复用。 + * + * `raw` 是逃生舱:当需要框架特有能力(setCookie/redirect/stream/文件下载等)时, + * 通过 `reply.raw` 访问框架原生对象。类型由 `ERest` 的 Raw 泛型驱动, + * 经子包 `createERest()` 工厂在构造时锁定。 */ -export interface Reply { +export interface Reply { /** 设置 HTTP 状态码并返回自身,支持链式调用 */ - status(code: number): Reply; + status(code: number): this; /** 以 JSON 写入响应体 */ json(body: unknown): void; /** 以纯文本写入响应体 */ send(body: string): void; + /** 框架原生对象逃生舱(setCookie/redirect/stream/文件下载等) */ + readonly raw: Raw; } /** @@ -103,7 +109,7 @@ export interface Context { /** 跨中间件传递数据的可读写状态(替代直接写 req/ctx.currentUser) */ readonly state: Record; /** 框架无关响应接口(复用 Reply;中间件可提前响应/终止) */ - readonly reply: Reply; + readonly reply: Reply; /** 校验后分层参数(由 checker 注入;before/middleware 执行时尚未填充) */ $validated?: { params: Record; From abef187768084f7e990530ba6c230a25b5faad4a Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 29 Jun 2026 12:00:27 +0800 Subject: [PATCH 05/14] =?UTF-8?q?feat(api):=20API=20=E9=80=8F?= =?UTF-8?q?=E4=BC=A0=20Raw=EF=BC=8CregisterTyped=20reply=20=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E7=94=A8=20Reply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 类级加 Raw 泛型维度(默认 unknown) - registerTyped handler 的 reply 参数类型用 Reply - wrappedHandler 调用处 ctx.reply 断言为 Reply(运行时即 adapter 注入的 reply, Context 静态类型为 Reply,断言模式同 typedReq) - 现有 5 个方法级 Zod 泛型不变,Raw 与之正交 - tsc 通过,267 测试全通过 --- src/lib/api.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 0d00399..5ff4818 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -69,7 +69,7 @@ export interface APIOption extends Record { compiled?: CompiledRoute; } -class API { +class API { public key: string; public pathTestRegExp: RegExp; public inited: boolean; @@ -334,7 +334,7 @@ class API { params: z.infer>; headers: z.infer>; }, - reply: Reply + reply: Reply ) => z.infer | Promise> | void | Promise ) { this.checkInited(); @@ -376,7 +376,10 @@ class API { headers: z.infer>; }; - const result = handler(typedReq, ctx.reply); + // ctx.reply 运行时是 adapter 注入的 reply(含对应框架的 raw 值), + // 仅 Context 静态类型为 Reply。Raw 由 ERest/API 类泛型锁定, + // 与 adapter 注入的 raw 值一致,故此处断言安全(同 typedReq 的断言模式)。 + const result = handler(typedReq, ctx.reply as Reply); // 若 handler 返回了值且定义了 response schema,则校验返回值(纯计算型 handler 场景) if (schemas.response && result !== undefined) { From f48e4e9ec2a3c73007de1620543b00d82eb077a0 Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 29 Jun 2026 12:02:32 +0800 Subject: [PATCH 06/14] =?UTF-8?q?feat(erest):=20ERest=20/=20IGroup=20/=20genSchema=20=E5=85=A8=E9=93=BE=E8=B7=AF=E9=80=8F?= =?UTF-8?q?=E4=BC=A0=20Raw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - genSchema/IGroup/IApiInfo 加 Raw 维度(默认 unknown) - ERest,registAPI/defineAPI 字段类型返回 API - group() 返回 IGroup,new API - API.define 静态方法加 Raw 维度,defineAPI 调用处显式传 Raw - 现有 new ERest() 行为不变(Raw 默认 unknown),tsc 通过,267 测试全通过 --- src/lib/api.ts | 4 ++-- src/lib/index.ts | 34 +++++++++++++++++----------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 5ff4818..ce66135 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -109,8 +109,8 @@ class API { debug("new: %s %s from %s", method, path, sourceFile.absolute); } - public static define(options: APIDefine, sourceFile: SourceResult, group?: string, prefix?: string) { - const schema = new API(options.method, options.path, sourceFile, group, prefix); + public static define(options: APIDefine, sourceFile: SourceResult, group?: string, prefix?: string) { + const schema = new API(options.method, options.path, sourceFile, group, prefix); schema.title(options.title); const g = group || options.group; if (g) { diff --git a/src/lib/index.ts b/src/lib/index.ts index c9064da..61ed278 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -33,19 +33,19 @@ const invalidParameter = (msg: string) => const internalError = (msg: string) => new ERestError("INTERNAL_ERROR", `internal error ${msg}`, undefined, 500); /** Schema方法 */ -export type genSchema = Readonly API>>; +export type genSchema = Readonly API>>; /** 组方法 */ -export interface IGroup extends Record, genSchema { - define: (opt: APIDefine) => API; - before: (...fn: T[]) => IGroup; - middleware: (...fn: T[]) => IGroup; +export interface IGroup extends Record, genSchema { + define: (opt: APIDefine) => API; + before: (...fn: T[]) => IGroup; + middleware: (...fn: T[]) => IGroup; } /** API接口定义 */ -export interface IApiInfo extends Record, genSchema { - readonly $apis: Map>; - define: (opt: APIDefine) => API; +export interface IApiInfo extends Record, genSchema { + readonly $apis: Map>; + define: (opt: APIDefine) => API; beforeHooks: Set; afterHooks: Set; docs?: IAPIDoc; @@ -121,11 +121,11 @@ interface IGroupInfo extends IGroupInfoOpt { /** * Easy rest api helper */ -class ERest { +class ERest { public shareTestData?: unknown; public utils = utils; - private apiInfo: IApiInfo; + private apiInfo: IApiInfo; private testAgent: IAPITest = {} as IAPITest; private app: unknown; private info: IApiOptionInfo; @@ -148,8 +148,8 @@ class ERest { path: string, group?: string | undefined, prefix?: string | undefined - ) => API; - private defineAPI: (options: APIDefine, group?: string | undefined, prefix?: string | undefined) => API; + ) => API; + private defineAPI: (options: APIDefine, group?: string | undefined, prefix?: string | undefined) => API; private mockHandler?: (data: unknown) => T; /** @internal 错误工厂(adapter/params/api 用,替代 privateInfo 反射) */ @@ -319,7 +319,7 @@ class ERest { } else { assert(!group, "请开启 forceGroup 再使用 group 功能"); } - const s = new API(method, path, getCallerSourceLine(this.config.path), group, prefix); + const s = new API(method, path, getCallerSourceLine(this.config.path), group, prefix); const s2 = this.apiInfo.$apis.get(s.key); assert( !s2, @@ -334,7 +334,7 @@ class ERest { }; // define注册方法 this.defineAPI = (opt: APIDefine, group?: string, prefix?: string) => { - const s = API.define(opt, getCallerSourceLine(this.config.path), group, prefix); + const s = API.define(opt, getCallerSourceLine(this.config.path), group, prefix); const s2 = this.apiInfo.$apis.get(s.key); assert( !s2, @@ -460,9 +460,9 @@ class ERest { /** * 获取分组API实例 */ - public group(name: string, info?: IGroupInfoOpt): IGroup; - public group(name: string, desc?: string): IGroup; - public group(name: string, infoOrDesc?: IGroupInfoOpt | string): IGroup { + public group(name: string, info?: IGroupInfoOpt): IGroup; + public group(name: string, desc?: string): IGroup; + public group(name: string, infoOrDesc?: IGroupInfoOpt | string): IGroup { debug("using group: %s, desc: %j", name, infoOrDesc); // assert(this.groupInfo[name], `请先配置 ${name} 分组`); const info = !infoOrDesc || typeof infoOrDesc === "string" ? { name: infoOrDesc, prefix: "" } : infoOrDesc; From 64969bbc255d448c15a2a051c9092f7180a63942 Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 29 Jun 2026 12:05:21 +0800 Subject: [PATCH 07/14] =?UTF-8?q?feat(error):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=8F=AF=E9=80=89=20defaultErrorFormatter=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工具函数,用户在 app 级错误中间件调用,实现三框架统一错误响应体 ({error, code} 结构,statusCode 取 ERestError.statusCode 或 status,默认 400)。 不改 adapter、不破坏 re-throw 对齐(commit 727b2c0)。 经 index.ts 的 export * 自动从核心包导出。 --- src/lib/error.ts | 38 ++++++++++++++++++++++++++++++++ src/test/test-error-formatter.ts | 36 ++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 src/test/test-error-formatter.ts diff --git a/src/lib/error.ts b/src/lib/error.ts index 97f6dec..3704de4 100644 --- a/src/lib/error.ts +++ b/src/lib/error.ts @@ -76,3 +76,41 @@ export class ERestError extends Error { }; } } + +/** + * 错误响应格式器:把任意错误归一化为 { status, body } 结构。 + * + * 用户在 app 级错误中间件中调用(三框架一行替换),实现跨框架统一的错误响应体。 + * 不由 adapter 内置——adapter 只做桥接,错误响应格式由用户在 app 级决定。 + * + * @example + * // Express + * app.use((err, _req, res, _next) => { + * const { status, body } = defaultErrorFormatter(err); + * res.status(status).json(body); + * }); + * // Koa + * app.use(async (ctx, next) => { + * try { await next(); } + * catch (err) { + * const { status, body } = defaultErrorFormatter(err); + * ctx.status = status; ctx.body = body; + * } + * }); + */ +export interface ErrorFormatter { + (err: ERestError | Error): { status: number; body: unknown }; +} + +/** 内置默认格式器(与 examples 错误中间件一致),可直接用或自定义 */ +export const defaultErrorFormatter: ErrorFormatter = (err) => { + const e = err as ERestError & { status?: number }; + const status = e.statusCode || e.status || 400; + return { + status, + body: { + error: err.message, + code: e.code ?? "INTERNAL_ERROR", + }, + }; +}; diff --git a/src/test/test-error-formatter.ts b/src/test/test-error-formatter.ts new file mode 100644 index 0000000..1b90df3 --- /dev/null +++ b/src/test/test-error-formatter.ts @@ -0,0 +1,36 @@ +/** + * @file defaultErrorFormatter 测试 + * 验证错误格式器对 ERestError / 普通 Error 输出一致的 {status, body} 结构 + */ +import { describe, expect, it } from "vitest"; +import { ERestError, defaultErrorFormatter } from "../lib/error.js"; + +describe("defaultErrorFormatter", () => { + it("ERestError 输出 statusCode 与 code/message body", () => { + const err = new ERestError("AUTH_REQUIRED", "未登录", undefined, 401); + const out = defaultErrorFormatter(err); + expect(out.status).toBe(401); + expect(out.body).toEqual({ error: "未登录", code: "AUTH_REQUIRED" }); + }); + + it("ERestError 默认 statusCode 400", () => { + const err = new ERestError("VALIDATION_ERROR", "校验失败"); + const out = defaultErrorFormatter(err); + expect(out.status).toBe(400); + expect(out.body).toEqual({ error: "校验失败", code: "VALIDATION_ERROR" }); + }); + + it("普通 Error 退化为 400 + INTERNAL_ERROR", () => { + const err = new Error("boom"); + const out = defaultErrorFormatter(err); + expect(out.status).toBe(400); + expect(out.body).toEqual({ error: "boom", code: "INTERNAL_ERROR" }); + }); + + it("带 status 属性的 Error 取 status", () => { + const err = Object.assign(new Error("forbidden"), { status: 403 }); + const out = defaultErrorFormatter(err); + expect(out.status).toBe(403); + expect(out.body).toEqual({ error: "forbidden", code: "INTERNAL_ERROR" }); + }); +}); From e5c6b2cc1026223b9f02f83f69df7671b0380632 Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 29 Jun 2026 12:11:27 +0800 Subject: [PATCH 08/14] =?UTF-8?q?feat(express):=20reply.raw=20=E5=A1=9E?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E5=AF=B9=E8=B1=A1=20+=20=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=20ExpressRaw=20+=20createERest=20=E5=B7=A5=E5=8E=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createExpressReply 接收 req/res,reply.raw = { req, res }(热路径零分配, 仅多挂闭包已有引用) - 导出 ExpressRaw 类型别名;ExpressAdapter implements FrameworkAdapter - 新增 createERest() 工厂构造时锁定 Raw 泛型(handler reply.raw 零标注强类型) - test-raw.ts 验证 Express handler 通过 reply.raw.res.cookie 设置 Set-Cookie - http-req helper 扩展 headers 字段(验证响应头所需,纯增量不破坏现有测试) --- packages/erest-express/src/index.ts | 31 +++++++++++++++---- src/test/http-req.ts | 13 +++++++- src/test/test-raw.ts | 46 +++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 src/test/test-raw.ts diff --git a/packages/erest-express/src/index.ts b/packages/erest-express/src/index.ts index 59597fa..58394cf 100644 --- a/packages/erest-express/src/index.ts +++ b/packages/erest-express/src/index.ts @@ -5,9 +5,10 @@ import type { API } from "erest"; import type { ERest } from "erest"; +import { ERest as ERestCtor, compose } from "erest"; import type { LifecycleHooks } from "erest"; import type { Context, FrameworkAdapter, Middleware, Reply } from "erest"; -import { compose } from "erest"; +import type { Request, Response } from "express"; /** * Express framework adapter @@ -16,7 +17,10 @@ import { compose } from "erest"; * 并用 compose 串起标准化 handler 链。框架只看到这一个中间件,避开 Express * 对 handler 数组的签名假设。 */ -export class ExpressAdapter implements FrameworkAdapter { +/** Express 原生对象类型(reply.raw 在 Express 下为此类型) */ +export type ExpressRaw = { req: Request; res: Response }; + +export class ExpressAdapter implements FrameworkAdapter { readonly name = "express" as const; /** @@ -71,7 +75,7 @@ export class ExpressAdapter implements FrameworkAdapter { // Stage 3:零开销裁剪——无 hooks 时装配不含 hook 调用的 dispatch const hasHook = Boolean(hooks && (hooks.onRequest || hooks.onValidate || hooks.onError || hooks.onResponse)); const nativeMiddleware = (req: Record, res: unknown, next: (err?: unknown) => void) => { - const reply: Reply = createExpressReply(res); + const reply: Reply = createExpressReply(req, res); const ctx: Context = { method: String(req.method ?? "").toUpperCase(), path: String(req.path ?? req.url ?? ""), @@ -130,10 +134,10 @@ export class ExpressAdapter implements FrameworkAdapter { } } -/** 构造 Express 的 Reply 封装 */ -function createExpressReply(res: unknown): Reply { +/** 构造 Express 的 Reply 封装(含 raw 逃生舱:原生 { req, res }) */ +function createExpressReply(req: unknown, res: unknown): Reply { const expressRes = res as { status?: (c: number) => unknown; json?: (b: unknown) => void; end?: (b: string) => void }; - const reply: Reply = { + const reply: Reply = { status(code: number) { expressRes.status?.(code); return reply; @@ -144,8 +148,23 @@ function createExpressReply(res: unknown): Reply { send(body: string) { expressRes.end?.(body); }, + raw: { req, res } as ExpressRaw, }; return reply; } export const expressAdapter = new ExpressAdapter(); + +/** + * 创建绑定 Express 原生类型的 ERest 实例(构造时锁定 Raw 泛型)。 + * + * 返回的 ERest 实例中,registerTyped handler 的 reply.raw 自动推导为 ExpressRaw, + * 无需手动标注。等价于 `new ERest(options)`。 + * + * @example + * import { createERest } from "@erest/express"; + * const api = createERest({ info, groups, forceGroup }); + */ +export function createERest(options: ConstructorParameters[0]): ERest { + return new ERestCtor(options); +} diff --git a/src/test/http-req.ts b/src/test/http-req.ts index 292b4c1..64a6af8 100644 --- a/src/test/http-req.ts +++ b/src/test/http-req.ts @@ -18,6 +18,8 @@ export interface HttpResponse { status: number; body: unknown; text: string; + /** 响应头(小写键名,支持读取 set-cookie 等原生头) */ + headers: Record; } interface ReqState { @@ -67,6 +69,15 @@ export function httpReq(app: unknown) { } const res = await fetch(t.baseUrl + state.path, { method: state.method, headers, body }); const text = await res.text(); + // 收集响应头为 plain object(小写键名);set-cookie 保留为数组以支持多值 + const respHeaders: Record = {}; + res.headers.forEach((value, key) => { + respHeaders[key.toLowerCase()] = value; + }); + const setCookie = res.headers.getSetCookie?.(); + if (setCookie && setCookie.length > 0) { + respHeaders["set-cookie"] = setCookie.length === 1 ? setCookie[0] : setCookie; + } let parsed: unknown = {}; const ct = res.headers.get("content-type") || ""; if ((ct.includes("json") || text.startsWith("{") || text.startsWith("[")) && text) { @@ -76,7 +87,7 @@ export function httpReq(app: unknown) { parsed = {}; } } - return onFulfilled({ status: res.status, body: parsed, text }); + return onFulfilled({ status: res.status, body: parsed, text, headers: respHeaders }); } catch (err) { if (onRejected) return onRejected(err); throw err; diff --git a/src/test/test-raw.ts b/src/test/test-raw.ts new file mode 100644 index 0000000..c4efd41 --- /dev/null +++ b/src/test/test-raw.ts @@ -0,0 +1,46 @@ +/** + * @file reply.raw 逃生舱集成测试 + * 验证三框架 handler 通过 reply.raw 访问原生对象(以 setCookie/原生响应头为例) + */ +import express from "express"; +import Koa from "koa"; +import bodyParser from "koa-bodyparser"; +import KoaRouter from "koa-router"; +import { Application, component, Router } from "@leizm/web"; +import { expressAdapter, koaAdapter, leizmwebAdapter } from "./adapters"; +import { httpReq as request } from "./http-req"; +import { afterAll, describe, expect, it } from "vitest"; +import { z } from "zod"; +import lib from "./lib"; + +// ---------------- Express ---------------- +describe("reply.raw - Express 集成", () => { + const app = express(); + app.use(express.json()); + + const apiService = lib({ basePath: "" }); + apiService.api + .post("/login") + .group("Index") + .title("login-express") + .registerTyped({ body: z.object({ user: z.string() }) }, (_req, reply) => { + // reply.raw 在 Express 下为 { req, res },通过 res.cookie 设置 cookie + const res = (reply as { raw: { res: express.Response } }).raw.res; + res.cookie("token", "abc-123", { httpOnly: true }); + reply.json({ ok: true }); + }); + + apiService.bind({ adapter: expressAdapter, router: app }); + + it("handler 能通过 reply.raw.res.cookie 设置 Set-Cookie 响应头", async () => { + const res = await request(app).post("/login").send({ user: "Tom" }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + const setCookie = res.headers["set-cookie"]; + expect(setCookie).toBeDefined(); + expect(String(setCookie)).toContain("token=abc-123"); + expect(String(setCookie)).toContain("HttpOnly"); + }); +}); + +afterAll(() => {}); From 59436ac1564d52b6dcaba587a3b91bb044037927 Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 29 Jun 2026 12:19:06 +0800 Subject: [PATCH 09/14] =?UTF-8?q?feat(koa):=20reply.raw=20=E5=A1=9E?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=20ctx=20+=20=E5=AF=BC=E5=87=BA=20KoaRaw=20+?= =?UTF-8?q?=20createERest=20=E5=B7=A5=E5=8E=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createKoaReply 返回 Reply,reply.raw = 原生 ctx(热路径零分配) - 导出 KoaRaw 类型别名;KoaAdapter implements FrameworkAdapter - 新增 createERest() 工厂构造时锁定 Raw 泛型 - test-raw.ts 验证 Koa handler 通过 reply.raw.cookies.set 设置 Set-Cookie (Koa cookies 库输出小写 httponly,断言大小写不敏感) --- packages/erest-koa/src/index.ts | 27 +++++++++++++++++---- src/test/test-raw.ts | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/packages/erest-koa/src/index.ts b/packages/erest-koa/src/index.ts index e64d454..c019b85 100644 --- a/packages/erest-koa/src/index.ts +++ b/packages/erest-koa/src/index.ts @@ -5,9 +5,13 @@ import type { API } from "erest"; import type { ERest } from "erest"; +import { ERest as ERestCtor, compose } from "erest"; import type { LifecycleHooks } from "erest"; import type { Context, FrameworkAdapter, Middleware, Reply } from "erest"; -import { compose } from "erest"; +import type { Context as KoaContext } from "koa"; + +/** Koa 原生对象类型(reply.raw 在 Koa 下为原生 Context) */ +export type KoaRaw = KoaContext; /** * Koa framework adapter @@ -15,7 +19,7 @@ import { compose } from "erest"; * 标准化改造后:bindRoute 注册一个 Koa 原生中间件,内部构造标准 Context * 并用 compose 串起标准化 handler 链。 */ -export class KoaAdapter implements FrameworkAdapter { +export class KoaAdapter implements FrameworkAdapter { readonly name = "koa" as const; /** @@ -130,10 +134,10 @@ export class KoaAdapter implements FrameworkAdapter { } } -/** 构造 Koa 的 Reply 封装(写 ctx.status / ctx.body) */ -function createKoaReply(ctx: Record): Reply { +/** 构造 Koa 的 Reply 封装(写 ctx.status / ctx.body;含 raw 逃生舱:原生 ctx) */ +function createKoaReply(ctx: Record): Reply { const koaCtx = ctx as { status?: number; body?: unknown; type?: string }; - const reply: Reply = { + const reply: Reply = { status(code: number) { koaCtx.status = code; return reply; @@ -145,8 +149,21 @@ function createKoaReply(ctx: Record): Reply { send(body: string) { koaCtx.body = body; }, + raw: ctx as KoaRaw, }; return reply; } export const koaAdapter = new KoaAdapter(); + +/** + * 创建绑定 Koa 原生类型的 ERest 实例(构造时锁定 Raw 泛型)。 + * handler 的 reply.raw 自动推导为 KoaRaw(原生 Context),无需手动标注。 + * + * @example + * import { createERest } from "@erest/koa"; + * const api = createERest({ info, groups, forceGroup }); + */ +export function createERest(options: ConstructorParameters[0]): ERest { + return new ERestCtor(options); +} diff --git a/src/test/test-raw.ts b/src/test/test-raw.ts index c4efd41..289dd8b 100644 --- a/src/test/test-raw.ts +++ b/src/test/test-raw.ts @@ -44,3 +44,45 @@ describe("reply.raw - Express 集成", () => { }); afterAll(() => {}); + +// ---------------- Koa ---------------- +describe("reply.raw - Koa 集成", () => { + it("handler 能通过 reply.raw.cookies.set 设置 Set-Cookie", async () => { + const app = new Koa(); + app.use(bodyParser()); + app.use(async (ctx, next) => { + try { + await next(); + } catch (err: unknown) { + ctx.status = (err as { statusCode?: number }).statusCode || 400; + ctx.body = { message: (err as Error).message }; + } + }); + + const apiService = lib({ basePath: "" }); + const router = new KoaRouter(); + apiService.api + .post("/login") + .group("Index") + .title("login-koa") + .registerTyped({ body: z.object({ user: z.string() }) }, (_req, reply) => { + // reply.raw 在 Koa 下为原生 ctx + const ctx = (reply as { raw: { cookies: { set: (n: string, v: string, o: unknown) => void } } }).raw; + ctx.cookies.set("token", "koa-456", { httpOnly: true }); + reply.json({ ok: true }); + }); + apiService.bind({ adapter: koaAdapter, router }); + app.use(router.routes()).use(router.allowedMethods()); + + const server = app.listen(); + const res = await request(server).post("/login").send({ user: "Jerry" }); + server.close(); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + const setCookie = res.headers["set-cookie"]; + expect(setCookie).toBeDefined(); + expect(String(setCookie)).toContain("token=koa-456"); + // Koa cookies 库输出小写 httponly,大小写不敏感断言 + expect(String(setCookie).toLowerCase()).toContain("httponly"); + }); +}); From 863b33a69c8321972950bdd459930d03089d49f2 Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 29 Jun 2026 12:24:49 +0800 Subject: [PATCH 10/14] =?UTF-8?q?feat(leizmweb):=20reply.raw=20=E5=A1=9E?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=20ctx=20+=20=E5=AF=BC=E5=87=BA=20LeizmWebRaw?= =?UTF-8?q?=20+=20createERest=20=E5=B7=A5=E5=8E=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createLeiReply 返回 Reply,reply.raw = 原生 ctx(热路径零分配) - 导出 LeizmWebRaw 类型别名(@leizm/web Context) - LeizmWebAdapter implements FrameworkAdapter - 新增 createERest() 工厂构造时锁定 Raw 泛型 - test-raw.ts 验证 handler 通过 reply.raw.response.setHeader 设置响应头 (leizmweb cookie API 随版本变化,用 setHeader 作稳定证明) --- packages/erest-leizmweb/src/index.ts | 27 +++++++++++++++++++----- src/test/test-raw.ts | 31 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/erest-leizmweb/src/index.ts b/packages/erest-leizmweb/src/index.ts index 6bafcc3..9404724 100644 --- a/packages/erest-leizmweb/src/index.ts +++ b/packages/erest-leizmweb/src/index.ts @@ -5,9 +5,13 @@ import type { API } from "erest"; import type { ERest } from "erest"; +import { ERest as ERestCtor, compose } from "erest"; import type { LifecycleHooks } from "erest"; import type { Context, FrameworkAdapter, Middleware, Reply } from "erest"; -import { compose } from "erest"; +import type { Context as LeiContext } from "@leizm/web"; + +/** @leizm/web 原生对象类型(reply.raw 在 @leizm/web 下为原生 Context) */ +export type LeizmWebRaw = LeiContext; /** * @leizm/web framework adapter @@ -15,7 +19,7 @@ import { compose } from "erest"; * 标准化改造后:bindRoute 注册单个 @leizm/web 中间件(单参数,避开按 .length 区分 * 错误中间件的约束),内部构造标准 Context 并用 compose 串起标准化 handler 链。 */ -export class LeizmWebAdapter implements FrameworkAdapter { +export class LeizmWebAdapter implements FrameworkAdapter { readonly name = "leizmweb" as const; /** @@ -128,14 +132,14 @@ export class LeizmWebAdapter implements FrameworkAdapter { } } -/** 构造 @leizm/web 的 Reply 封装(写 ctx.response.json/status/send) */ -function createLeiReply(ctx: Record): Reply { +/** 构造 @leizm/web 的 Reply 封装(写 ctx.response.json/status/send;含 raw 逃生舱:原生 ctx) */ +function createLeiReply(ctx: Record): Reply { const leiRes = (ctx.response ?? {}) as { status?: (c: number) => unknown; json?: (b: unknown) => void; send?: (b: string) => void; }; - const reply: Reply = { + const reply: Reply = { status(code: number) { leiRes.status?.(code); return reply; @@ -146,8 +150,21 @@ function createLeiReply(ctx: Record): Reply { send(body: string) { leiRes.send?.(body); }, + raw: ctx as LeizmWebRaw, }; return reply; } export const leizmWebAdapter = new LeizmWebAdapter(); + +/** + * 创建绑定 @leizm/web 原生类型的 ERest 实例(构造时锁定 Raw 泛型)。 + * handler 的 reply.raw 自动推导为 LeizmWebRaw(原生 Context),无需手动标注。 + * + * @example + * import { createERest } from "@erest/leizmweb"; + * const api = createERest({ info, groups, forceGroup }); + */ +export function createERest(options: ConstructorParameters[0]): ERest { + return new ERestCtor(options); +} diff --git a/src/test/test-raw.ts b/src/test/test-raw.ts index 289dd8b..ff8763d 100644 --- a/src/test/test-raw.ts +++ b/src/test/test-raw.ts @@ -86,3 +86,34 @@ describe("reply.raw - Koa 集成", () => { expect(String(setCookie).toLowerCase()).toContain("httponly"); }); }); + +// ---------------- @leizm/web ---------------- +describe("reply.raw - @leizm/web 集成", () => { + it("handler 能通过 reply.raw.response 设置响应头(raw 逃生舱可用)", async () => { + const app = new Application(); + app.use("/", component.bodyParser.json()); + + const apiService = lib({ basePath: "" }); + const router = new Router(); + apiService.api + .post("/login") + .group("Index") + .title("login-lei") + .registerTyped({ body: z.object({ user: z.string() }) }, (_req, reply) => { + // reply.raw 在 @leizm/web 下为原生 ctx;用设置自定义响应头作为 raw 可用性的稳定证明 + // (leizmweb cookie API 随版本变化,header 操作更稳) + const raw = (reply as { raw: { response: { setHeader: (k: string, v: string) => void } } }).raw; + raw.response.setHeader("X-Raw-Test", "leizmweb"); + reply.json({ ok: true }); + }); + apiService.bind({ adapter: leizmwebAdapter, router }); + app.use("/", router); + + const res = await request(app.server).post("/login").send({ user: "Anna" }); + app.server.close(); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + // 验证 raw 设置的自定义头生效(小写读取) + expect(res.headers["x-raw-test"]).toBe("leizmweb"); + }); +}); From 784a4680f24d14c737c4b970e78317f6954a5203 Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 29 Jun 2026 12:28:27 +0800 Subject: [PATCH 11/14] =?UTF-8?q?test+docs:=20reply.raw=20=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E6=8E=A8=E5=AF=BC=E6=B5=8B=E8=AF=95=E3=80=81=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E9=80=9F=E6=9F=A5=E8=A1=A8=E3=80=81=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=20new=20ERest()=20deprecated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test-builder-types.ts:新增 reply.raw 默认 unknown 类型断言 - test-raw-types.ts:验证 Express createERest 锁定 Raw,reply.raw 零标注强类型 - README:新增「原生能力逃生舱 reply.raw」章节 + 三框架能力速查表 - ERest 构造函数标记 @deprecated,引导到子包 createERest() - 修复 leizmweb ctx 断言(Record→LeizmWebRaw 需经 unknown,子包独立 build 严格) - npm test 全绿:核心 276 + examples 13 --- README.md | 37 ++++++++++++++++++++++++++++ packages/erest-leizmweb/src/index.ts | 2 +- src/lib/index.ts | 6 +++++ src/test/test-builder-types.ts | 10 ++++++++ src/test/test-raw-types.ts | 25 +++++++++++++++++++ 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 src/test/test-raw-types.ts diff --git a/README.md b/README.md index c917f1a..27c3987 100644 --- a/README.md +++ b/README.md @@ -434,6 +434,43 @@ api.api 无论哪种 API,**响应都通过 `reply` 写入**——不要用框架原生的 `res.json()` / `ctx.body =` / `ctx.response.json()`。 +### 原生能力逃生舱:`reply.raw` + +`reply` 只暴露 `json()/status()/send()` 三个框架无关方法。当需要框架特有能力(setCookie、redirect、流式响应、文件下载等)时,通过 `reply.raw` 访问框架原生对象——它是「逃生舱」,绕开标准抽象直达底层。 + +`reply.raw` 的类型由 **`ERest` 的 Raw 泛型**驱动。用子包提供的 `createERest()` 工厂创建实例,会在构造时自动锁定 Raw,handler 内 `reply.raw` 自动强类型、**零标注**: + +```typescript +import { createERest } from '@erest/express'; +// 用 createERest 代替 new ERest:构造时锁定 Raw = ExpressRaw({ req, res }) +const api = createERest({ info, groups, forceGroup: true }); + +api.api.post('/login').group('auth').title('登录').registerTyped( + { body: LoginSchema }, + (req, reply) => { + const user = authenticate(req.body); + // reply.raw 自动推导为 { req: Request; res: Response },无需断言 + reply.raw.res.cookie('token', sign(user), { httpOnly: true }); + reply.json({ ok: true }); + }, +); +``` + +> 直接 `new ERest()` 仍可用(过渡期保留),但 Raw 默认为 `unknown`,`reply.raw` 需手动断言。新代码推荐用子包 `createERest()`。 + +**框架原生能力速查表**(`reply.raw` 在三框架下的形态不同,签名/语义各自遵循原生约定): + +| 能力 | Express (`reply.raw`) | Koa (`reply.raw`) | @leizm/web (`reply.raw`) | +|------|------|------|------| +| 设置 cookie | `.res.cookie(name, val, opts)` | `.cookies.set(name, val, opts)` | `.response.setHeader('Set-Cookie', ...)` | +| 读取 cookie | `.req.cookies`(需 cookie-parser) | `.cookies.get(name)` | `.request.cookies` | +| 重定向 | `.res.redirect(code, url)` | `.redirect(url)` | `.response.redirect(...)` | +| 流式响应 | `.res.write()` / `.res.end()` | `.body = stream` | `.response.send()` | +| 设置响应头 | `.res.setHeader(k, v)` | `.set(k, v)` | `.response.setHeader(k, v)` | +| 响应头已发送 | `.res.headersSent` | `.res.headersSent` | 查原生 | + +> ⚠️ `raw` 的头部/cookie 操作应在 `reply.json()`/`reply.send()` **之前**调用(HTTP 头先于体发送)。`reply.raw` 是逃生舱,不参与框架无关复用——用了 raw 的 handler 即与具体框架耦合。 + ## 参数读取:`$params` 与分层访问器 adapter 的 checker 把校验后的参数注入到 erest 的标准化 `ctx` 上,handler 通过它们读取。 diff --git a/packages/erest-leizmweb/src/index.ts b/packages/erest-leizmweb/src/index.ts index 9404724..1474f5a 100644 --- a/packages/erest-leizmweb/src/index.ts +++ b/packages/erest-leizmweb/src/index.ts @@ -150,7 +150,7 @@ function createLeiReply(ctx: Record): Reply { send(body: string) { leiRes.send?.(body); }, - raw: ctx as LeizmWebRaw, + raw: ctx as unknown as LeizmWebRaw, }; return reply; } diff --git a/src/lib/index.ts b/src/lib/index.ts index 61ed278..d9470e9 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -289,6 +289,12 @@ class ERest { }; } + /** + * @deprecated 推荐使用子包工厂 `createERest()`(如 `@erest/express` / `@erest/koa` / + * `@erest/leizmweb` 导出的 createERest),以在构造时锁定 Raw 泛型,让 registerTyped + * handler 的 reply.raw 自动强类型。裸 `new ERest()` 在过渡期保留,Raw 默认 unknown, + * reply.raw 需手动断言。 + */ constructor(options: IApiOption) { this.info = options.info || {}; this.forceGroup = options.forceGroup || false; diff --git a/src/test/test-builder-types.ts b/src/test/test-builder-types.ts index 0cec229..9fecedb 100644 --- a/src/test/test-builder-types.ts +++ b/src/test/test-builder-types.ts @@ -32,4 +32,14 @@ describe("Builder 类型推导", () => { expectTypeOf(api.options.bodySchema).not.toBeAny(); expectTypeOf(api.options.querySchema).not.toBeAny(); }); + + test("new ERest() 时 reply.raw 为 unknown(默认 Raw)", () => { + apiService.api + .post("/raw-unknown") + .group("Index") + .registerTyped({ body: z.object({ x: z.number() }) }, (_req, reply) => { + // 默认 Raw=unknown:reply.raw 类型为 unknown,需断言才能用 + expectTypeOf(reply.raw).toEqualTypeOf(); + }); + }); }); diff --git a/src/test/test-raw-types.ts b/src/test/test-raw-types.ts new file mode 100644 index 0000000..8a499b2 --- /dev/null +++ b/src/test/test-raw-types.ts @@ -0,0 +1,25 @@ +/** + * @file reply.raw 框架泛型类型推导测试 + * 验证子包 createERest() 工厂构造时锁定 Raw,handler reply.raw 自动强类型 + */ +import type express from "express"; +import { describe, test, expectTypeOf } from "vitest"; +import { createERest as createExpressERest, type ExpressRaw } from "../../packages/erest-express/src/index.js"; + +describe("createERest 类型锁定", () => { + test("Express createERest 返回的实例 handler reply.raw 为 ExpressRaw(零标注强类型)", () => { + const api = createExpressERest({ + info: { title: "t", version: "1.0.0" }, + groups: { Index: "首页" }, + }); + api + .api.post("/raw-typed") + .group("Index") + .registerTyped({}, (_req, reply) => { + // 构造时锁定 Raw=ExpressRaw:reply.raw 自动强类型,handler 零标注 + expectTypeOf(reply.raw).toEqualTypeOf(); + // reply.raw.res 是 Express Response + expectTypeOf(reply.raw.res).toMatchTypeOf(); + }); + }); +}); From 15152772754675d299a6a0430361c6c240b9137f Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 29 Jun 2026 12:34:53 +0800 Subject: [PATCH 12/14] =?UTF-8?q?review-fix:=20=E4=BF=AE=E6=AD=A3=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E6=97=A0=E6=95=88=E9=94=99=E8=AF=AF=E7=A0=81?= =?UTF-8?q?=20+=20=E8=A1=A5=E5=85=85=E8=BF=81=E7=A7=BB=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E4=B8=8E=E6=96=AD=E8=A8=80=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test-error-formatter: AUTH_REQUIRED → PERMISSION_DENIED(前者不在 ERestErrorCode 联合类型, PERMISSION_DENIED 语义匹配 401) - leizmweb: raw 双重断言补注释说明为何需经 unknown(结构不充分重叠) - MIGRATION.md: 补充 reply.raw 逃生舱 + createERest 工厂说明(非破坏性,标记 deprecated) 最终 code review 遗留的 Critical(issue1) 与文档类 issue4/5 已处理。 issue2(测试文件未纳入类型检查)为既有基础设施缺口,非本次引入,留作后续。 --- MIGRATION.md | 18 ++++++++++++++++++ packages/erest-leizmweb/src/index.ts | 2 ++ src/test/test-error-formatter.ts | 4 ++-- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 6559685..0b3b287 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -251,3 +251,21 @@ instead」)。`@koa/router` 是官方维护的继任包,API 与 koa-router * `@koa/router`。erest 仓库内部测试仍用 koa-router(开发环境 deprecated warning 无害),不影响 发布的 peer 声明。 + +## 补充:reply.raw 原生能力逃生舱 + createERest 工厂 + +v3.0.x 新增 `reply.raw` 逃生舱与框架泛型驱动,**非破坏性变更**(现有 `new ERest()` 代码零改动)。 + +- `Reply` 接口新增 `readonly raw: Raw` 字段,`status()` 返回类型从 `Reply` 改为 `this`。 +- registerTyped 的 handler 通过 `reply.raw` 访问框架原生对象(setCookie/redirect/stream 等)。 +- 推荐用子包工厂 `createERest()`(`@erest/express` / `@erest/koa` / `@erest/leizmweb` 导出) + 代替 `new ERest()`,构造时自动锁定 Raw 泛型,handler 内 `reply.raw` 零标注强类型。 +- 裸 `new ERest()` 已标记 `@deprecated`(过渡期保留,Raw 默认 `unknown`,`reply.raw` 需断言)。 +- 新增可选工具函数 `defaultErrorFormatter`,用户在 app 级错误中间件调用以统一三框架错误响应体。 +- 能力差异(cookie/stream/redirect 等)不再逐个补 Reply 方法,统一通过 `reply.raw` + 文档速查表(见 README)暴露。 + +```diff +- const api = new ERest({ info, groups, forceGroup: true }); ++ import { createERest } from "@erest/express"; ++ const api = createERest({ info, groups, forceGroup: true }); // reply.raw 自动强类型 +``` diff --git a/packages/erest-leizmweb/src/index.ts b/packages/erest-leizmweb/src/index.ts index 1474f5a..477a6f9 100644 --- a/packages/erest-leizmweb/src/index.ts +++ b/packages/erest-leizmweb/src/index.ts @@ -150,6 +150,8 @@ function createLeiReply(ctx: Record): Reply { send(body: string) { leiRes.send?.(body); }, + // LeizmWebRaw 是 @leizm/web 的 Context(结构含 request/response/session 等), + // 与此处的 Record 不充分重叠,需经 unknown 双重断言(同运行时桥接) raw: ctx as unknown as LeizmWebRaw, }; return reply; diff --git a/src/test/test-error-formatter.ts b/src/test/test-error-formatter.ts index 1b90df3..a6cd30f 100644 --- a/src/test/test-error-formatter.ts +++ b/src/test/test-error-formatter.ts @@ -7,10 +7,10 @@ import { ERestError, defaultErrorFormatter } from "../lib/error.js"; describe("defaultErrorFormatter", () => { it("ERestError 输出 statusCode 与 code/message body", () => { - const err = new ERestError("AUTH_REQUIRED", "未登录", undefined, 401); + const err = new ERestError("PERMISSION_DENIED", "未登录", undefined, 401); const out = defaultErrorFormatter(err); expect(out.status).toBe(401); - expect(out.body).toEqual({ error: "未登录", code: "AUTH_REQUIRED" }); + expect(out.body).toEqual({ error: "未登录", code: "PERMISSION_DENIED" }); }); it("ERestError 默认 statusCode 400", () => { From 92e0445f870ecb3a4e5cb2bc0e98aa419e51b1c0 Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 29 Jun 2026 12:41:47 +0800 Subject: [PATCH 13/14] =?UTF-8?q?chore(release):=20bump=20=E5=90=84?= =?UTF-8?q?=E5=8C=85=E7=89=88=E6=9C=AC=E8=87=B3=203.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次 feature(reply.raw 逃生舱 + 全自动 Raw 泛型 + createERest 工厂 + defaultErrorFormatter)为非破坏性 minor,bump 版本: - erest 核心 3.0.1 → 3.1.0 - @erest/express / @erest/koa / @erest/leizmweb 3.0.1 → 3.1.0 - @erest/gen 0.1.0 → 3.1.0(统一 scope 版本,gen 本次未改动) - MIGRATION.md 新增 3.1.0 changelog 节(替换之前 append 的临时段) 内部依赖均用 workspace: 协议,无需手改。npm test 全绿(276+13)。 --- MIGRATION.md | 73 +++++++++++++++++++++------- package.json | 2 +- packages/erest-express/package.json | 2 +- packages/erest-gen/package.json | 2 +- packages/erest-koa/package.json | 2 +- packages/erest-leizmweb/package.json | 2 +- 6 files changed, 60 insertions(+), 23 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 0b3b287..ae2f5ef 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -167,6 +167,61 @@ npx erest-gen handler --from ./schemas/user.ts --group user --out ./handlers/use --- +## 3.1.0 — reply.raw 逃生舱 + 框架能力对齐 + +3.1.0 是 **3.0 的 minor 版本**,引入框架原生能力逃生舱与统一错误格式器,**非破坏性变更** +(现有 `new ERest()` 代码零改动,examples 测试全绿)。 + +### 1. `reply.raw` 逃生舱 + +`Reply` 接口新增 `readonly raw: Raw` 字段(`status()` 返回类型从 `Reply` 收紧为 `this`)。 +registerTyped 的 handler 通过 `reply.raw` 访问框架原生对象,解决 setCookie / redirect / +stream / 文件下载等底层能力此前完全无法访问的问题。 + +`Context.reply` 仍保持 `Reply`——before/middleware/hook 不暴露 raw,仅 handler 拿到 +(避免 onError hook 与 re-throw 语义冲突的双重写入)。 + +### 2. 全自动 Raw 泛型 + `createERest()` 工厂 + +`ERest` / `API` / `IGroup` / `genSchema` 全链路透传 Raw, +registerTyped handler 的 reply 类型随之用 `Reply`。子包工厂在**构造时**锁定 Raw: + +```diff +- const api = new ERest({ info, groups, forceGroup: true }); ++ import { createERest } from "@erest/express"; // 或 @erest/koa / @erest/leizmweb ++ const api = createERest({ info, groups, forceGroup: true }); // reply.raw 零标注强类型 +``` + +- `createERest()` 由三子包分别导出(`@erest/express|koa|leizmweb`),各自返回 + `ERest`,handler 内 `reply.raw` 自动推导。 +- 裸 `new ERest()` 已标记 `@deprecated`(过渡期保留,Raw 默认 `unknown`,`reply.raw` 需断言)。 +- 三框架 adapter 在 `createXxxReply` 时把闭包已有的原生对象挂到 `raw`(热路径零额外分配, + Koa/leizmweb 仅多挂引用,Express 多一个 `{req,res}` 字面量)。 + +### 3. 可选统一错误格式器 `defaultErrorFormatter` + +新增工具函数(不改 adapter、不破坏 commit 727b2c0 的 re-throw 对齐),用户在 app 级错误中间件 +调用以统一三框架错误响应体: + +```typescript +import { defaultErrorFormatter } from "erest"; +// Express +app.use((err, _req, res, _next) => { + const { status, body } = defaultErrorFormatter(err); // { status, body: { error, code } } + res.status(status).json(body); +}); +``` + +### 4. 能力对齐:仅 `raw` + 文档速查表 + +cookie/stream/redirect/headersSent 等原生能力差异不再逐个补 Reply 方法,统一通过 `reply.raw` +暴露,并在 README 提供「三框架原生能力速查表」标注各框架语义差异。错误处理(re-throw 语义) +已在 3.0.x 对齐,本次不动。 + +> raw 的头部/cookie 操作应在 `reply.json()`/`reply.send()` 之前调用(HTTP 头先于体发送)。 + +--- + ## 3.0.1 — 文档与发布修复 3.0.1 是 **3.0 的补丁版本**,无运行时 breaking change,修复发布缺陷与文档错误: @@ -251,21 +306,3 @@ instead」)。`@koa/router` 是官方维护的继任包,API 与 koa-router * `@koa/router`。erest 仓库内部测试仍用 koa-router(开发环境 deprecated warning 无害),不影响 发布的 peer 声明。 - -## 补充:reply.raw 原生能力逃生舱 + createERest 工厂 - -v3.0.x 新增 `reply.raw` 逃生舱与框架泛型驱动,**非破坏性变更**(现有 `new ERest()` 代码零改动)。 - -- `Reply` 接口新增 `readonly raw: Raw` 字段,`status()` 返回类型从 `Reply` 改为 `this`。 -- registerTyped 的 handler 通过 `reply.raw` 访问框架原生对象(setCookie/redirect/stream 等)。 -- 推荐用子包工厂 `createERest()`(`@erest/express` / `@erest/koa` / `@erest/leizmweb` 导出) - 代替 `new ERest()`,构造时自动锁定 Raw 泛型,handler 内 `reply.raw` 零标注强类型。 -- 裸 `new ERest()` 已标记 `@deprecated`(过渡期保留,Raw 默认 `unknown`,`reply.raw` 需断言)。 -- 新增可选工具函数 `defaultErrorFormatter`,用户在 app 级错误中间件调用以统一三框架错误响应体。 -- 能力差异(cookie/stream/redirect 等)不再逐个补 Reply 方法,统一通过 `reply.raw` + 文档速查表(见 README)暴露。 - -```diff -- const api = new ERest({ info, groups, forceGroup: true }); -+ import { createERest } from "@erest/express"; -+ const api = createERest({ info, groups, forceGroup: true }); // reply.raw 自动强类型 -``` diff --git a/package.json b/package.json index 3e0fdf3..807d548 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "erest", - "version": "3.0.1", + "version": "3.1.0", "description": "Framework-agnostic REST API framework with Zod validation, auto docs & test scaffolding. Express/Koa/@leizm/web adapters as separate packages.", "type": "module", "main": "dist/lib/index.js", diff --git a/packages/erest-express/package.json b/packages/erest-express/package.json index 2759f03..e4e8da0 100644 --- a/packages/erest-express/package.json +++ b/packages/erest-express/package.json @@ -1,6 +1,6 @@ { "name": "@erest/express", - "version": "3.0.1", + "version": "3.1.0", "description": "Express adapter for erest", "type": "module", "main": "./dist/index.js", diff --git a/packages/erest-gen/package.json b/packages/erest-gen/package.json index 5a9d064..2e31780 100644 --- a/packages/erest-gen/package.json +++ b/packages/erest-gen/package.json @@ -1,6 +1,6 @@ { "name": "@erest/gen", - "version": "0.1.0", + "version": "3.1.0", "description": "erest codegen CLI:从 Zod schema 生成 handler 骨架等(实验性,独立于 erest 主版本演进)", "type": "module", "bin": { diff --git a/packages/erest-koa/package.json b/packages/erest-koa/package.json index 9a416a0..61d300b 100644 --- a/packages/erest-koa/package.json +++ b/packages/erest-koa/package.json @@ -1,6 +1,6 @@ { "name": "@erest/koa", - "version": "3.0.1", + "version": "3.1.0", "description": "Koa adapter for erest", "type": "module", "main": "./dist/index.js", diff --git a/packages/erest-leizmweb/package.json b/packages/erest-leizmweb/package.json index 315ff6d..0a659a3 100644 --- a/packages/erest-leizmweb/package.json +++ b/packages/erest-leizmweb/package.json @@ -1,6 +1,6 @@ { "name": "@erest/leizmweb", - "version": "3.0.1", + "version": "3.1.0", "description": "@leizm/web adapter for erest", "type": "module", "main": "./dist/index.js", From 72b869eaf8081a36ee739d7c22438170f2c8c7b6 Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 29 Jun 2026 21:52:49 +0800 Subject: [PATCH 14/14] =?UTF-8?q?fix(lint):=20=E4=BF=AE=E5=A4=8D=20oxlint?= =?UTF-8?q?=20error=20+=20=E5=8A=A0=20husky/lint-staged=20pre-commit=20hoo?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 CI 失败的 2 个 oxlint error(本 PR 引入): - test-integration-leizmweb.ts: ctx 参数未使用 → _ctx - adapters/types.ts: FrameworkAdapter 的 Raw 泛型未使用 → JSDoc 说明 + 局部 eslint-disable(Raw 为类型标记,供子类 implements 锁定,不在方法签名出现) 新增 pre-commit hook 防止 CI 再次失败: - husky 9 + lint-staged 17 - .husky/pre-commit 跑 lint-staged - lint-staged 对暂存的 *.{ts,js} 跑 oxfmt --write + oxlint --fix - 已验证:oxlint error(如未使用参数)会阻止 commit --- .husky/pre-commit | 1 + package.json | 11 +- pnpm-lock.yaml | 229 ++++++++++++++++++++++++++ src/lib/adapters/types.ts | 5 + src/lib/api.ts | 7 +- src/test/test-integration-leizmweb.ts | 14 +- src/test/test-raw-types.ts | 4 +- 7 files changed, 262 insertions(+), 9 deletions(-) create mode 100644 .husky/pre-commit diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..5ee7abd --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm exec lint-staged diff --git a/package.json b/package.json index 807d548..5f39bf4 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "build:packages": "pnpm --filter @erest/express --filter @erest/koa --filter @erest/leizmweb --filter @erest/gen build", "publish:all": "pnpm publish -r --no-git-checks --access public", "prepublishOnly": "npm run format && npm run test", - "postpublish": "npm run tag && git push && git push --tags" + "postpublish": "npm run tag && git push && git push --tags", + "prepare": "husky" }, "repository": { "type": "git", @@ -74,9 +75,11 @@ "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", "express": "^5.1.0", + "husky": "^9.1.7", "koa": "^3.0.0", "koa-bodyparser": "^4.4.1", "koa-router": "^13.1.1", + "lint-staged": "^17.0.8", "memfs": "^4.36.0", "oxfmt": "0.56.0", "oxlint": "1.71.0", @@ -88,5 +91,11 @@ "onlyBuiltDependencies": [ "esbuild" ] + }, + "lint-staged": { + "*.{ts,js}": [ + "oxfmt --write", + "oxlint --fix" + ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1eb272..65ff81e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: express: specifier: ^5.1.0 version: 5.2.1 + husky: + specifier: ^9.1.7 + version: 9.1.7 koa: specifier: ^3.0.0 version: 3.0.0 @@ -57,6 +60,9 @@ importers: koa-router: specifier: ^13.1.1 version: 13.1.1 + lint-staged: + specifier: ^17.0.8 + version: 17.0.8 memfs: specifier: ^4.36.0 version: 4.36.0 @@ -950,6 +956,10 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -958,6 +968,10 @@ packages: resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} engines: {node: '>=12'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -966,6 +980,10 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1030,6 +1048,14 @@ packages: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + co-body@6.2.0: resolution: {integrity: sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==} engines: {node: '>=8.0.0'} @@ -1152,6 +1178,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1170,6 +1199,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1200,6 +1233,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.2.2: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} @@ -1250,6 +1286,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1297,6 +1337,11 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + hyperdyperid@1.2.0: resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} engines: {node: '>=10.18'} @@ -1324,6 +1369,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -1375,6 +1424,19 @@ packages: linkify-it@5.0.1: resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} + lint-staged@17.0.8: + resolution: {integrity: sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==} + engines: {node: '>=22.22.1'} + hasBin: true + + listr2@10.2.2: + resolution: {integrity: sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==} + engines: {node: '>=22.13.0'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + loupe@3.1.4: resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==} @@ -1451,6 +1513,10 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1501,6 +1567,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + oxfmt@0.56.0: resolution: {integrity: sha512-9Dv0wV3zKiyvhjD7bRKaInKmHQ1sPx3RGOjQkGFJbbdQ16576yf8qhMSO9Q9cvHcs+1NpBsRTkuDDYFFPTJ6gw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1562,6 +1632,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1598,6 +1672,13 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rollup@4.45.1: resolution: {integrity: sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1684,6 +1765,14 @@ packages: resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} engines: {node: '>=18'} + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1710,6 +1799,10 @@ packages: resolution: {integrity: sha512-jos8u++JKm0ARcSUTAZXOVC0mSox7Bhn6sBgty73P1f3JGf7yG2clTbBNHUdde/kdvP2FESam+vM6l8jBrNxHA==} engines: {node: '>=0.8.0'} + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1718,6 +1811,14 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1726,6 +1827,10 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} @@ -1749,6 +1854,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.14: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} @@ -1911,6 +2020,10 @@ packages: engines: {node: '>=8'} hasBin: true + wrap-ansi@10.0.0: + resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} + engines: {node: '>=20'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1919,6 +2032,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2535,16 +2652,24 @@ snapshots: mime-types: 3.0.1 negotiator: 1.0.0 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 ansi-styles@6.2.1: {} + ansi-styles@6.2.3: {} + argparse@2.0.1: {} assertion-error@2.0.1: {} @@ -2631,6 +2756,15 @@ snapshots: check-error@2.1.1: {} + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.1 + co-body@6.2.0: dependencies: '@hapi/bourne': 3.0.0 @@ -2721,6 +2855,8 @@ snapshots: ee-first@1.1.1: {} + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -2731,6 +2867,8 @@ snapshots: entities@4.5.0: {} + environment@1.1.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -2778,6 +2916,8 @@ snapshots: etag@1.8.1: {} + eventemitter3@5.0.4: {} + expect-type@1.2.2: {} express@5.2.1: @@ -2848,6 +2988,8 @@ snapshots: function-bind@1.1.2: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2916,6 +3058,8 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + husky@9.1.7: {} + hyperdyperid@1.2.0: {} iconv-lite@0.4.24: @@ -2934,6 +3078,10 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + is-promise@4.0.0: {} isexe@2.0.0: {} @@ -3016,6 +3164,31 @@ snapshots: dependencies: uc.micro: 2.1.0 + lint-staged@17.0.8: + dependencies: + listr2: 10.2.2 + picomatch: 4.0.4 + string-argv: 0.3.2 + tinyexec: 1.2.4 + optionalDependencies: + yaml: 2.9.0 + + listr2@10.2.2: + dependencies: + cli-truncate: 5.2.0 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 10.0.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.2 + loupe@3.1.4: {} lru-cache@10.4.3: {} @@ -3082,6 +3255,8 @@ snapshots: mime@2.6.0: {} + mimic-function@5.0.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -3118,6 +3293,10 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + oxfmt@0.56.0: dependencies: tinypool: 2.1.0 @@ -3187,6 +3366,8 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.4: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -3229,6 +3410,13 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + rfdc@1.4.1: {} + rollup@4.45.1: dependencies: '@types/estree': 1.0.8 @@ -3400,6 +3588,16 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + source-map-js@1.2.1: {} stackback@0.0.2: {} @@ -3414,6 +3612,8 @@ snapshots: streamsearch@0.1.2: {} + string-argv@0.3.2: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -3426,6 +3626,17 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.0 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.1.0 + + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -3434,6 +3645,10 @@ snapshots: dependencies: ansi-regex: 6.1.0 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-literal@3.0.0: dependencies: js-tokens: 9.0.1 @@ -3456,6 +3671,8 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.2.4: {} + tinyglobby@0.2.14: dependencies: fdir: 6.4.6(picomatch@4.0.3) @@ -3603,6 +3820,12 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wrap-ansi@10.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.1 + strip-ansi: 7.2.0 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -3615,6 +3838,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.2.0 + strip-ansi: 7.1.0 + wrappy@1.0.2: {} yaml@2.9.0: {} diff --git a/src/lib/adapters/types.ts b/src/lib/adapters/types.ts index dc1ded6..e933315 100644 --- a/src/lib/adapters/types.ts +++ b/src/lib/adapters/types.ts @@ -21,7 +21,12 @@ export interface IAdapterGroupInfo { * Framework adapter interface * Provides unified interface for different web frameworks (Express/Koa/@leizm/web built-in via subpackages; * third-party adapters implement this interface with an arbitrary `name`) + * + * `Raw` 泛型不在接口方法签名中出现——它仅作为类型标记,由具体 adapter 实现类通过 + * `implements FrameworkAdapter` 锁定,再经子包 `createERest()` 工厂透传到 + * registerTyped handler 的 `reply.raw`。故此处 no-unused-vars 不适用(类型层标记用法)。 */ +// 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) */ readonly name: string; diff --git a/src/lib/api.ts b/src/lib/api.ts index ce66135..b2d215d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -109,7 +109,12 @@ class API { debug("new: %s %s from %s", method, path, sourceFile.absolute); } - public static define(options: APIDefine, sourceFile: SourceResult, group?: string, prefix?: string) { + public static define( + options: APIDefine, + sourceFile: SourceResult, + group?: string, + prefix?: string + ) { const schema = new API(options.method, options.path, sourceFile, group, prefix); schema.title(options.title); const g = group || options.group; diff --git a/src/test/test-integration-leizmweb.ts b/src/test/test-integration-leizmweb.ts index 7bfafc9..31d2473 100644 --- a/src/test/test-integration-leizmweb.ts +++ b/src/test/test-integration-leizmweb.ts @@ -147,11 +147,15 @@ describe("@leizm/web Integration - 错误 re-throw", () => { const router = new Router(); // 故意抛一个带 statusCode 的业务错误 - api.get("/lei/boom").group("Index").title("抛错").register((ctx: any) => { - const e: any = new Error("业务错误"); - e.statusCode = 418; - throw e; - }); + api + .get("/lei/boom") + .group("Index") + .title("抛错") + .register((_ctx: any) => { + const e: any = new Error("业务错误"); + e.statusCode = 418; + throw e; + }); apiService.bind({ adapter: leizmwebAdapter, router }); app.use("/", router); diff --git a/src/test/test-raw-types.ts b/src/test/test-raw-types.ts index 8a499b2..3c6698c 100644 --- a/src/test/test-raw-types.ts +++ b/src/test/test-raw-types.ts @@ -12,8 +12,8 @@ describe("createERest 类型锁定", () => { info: { title: "t", version: "1.0.0" }, groups: { Index: "首页" }, }); - api - .api.post("/raw-typed") + api.api + .post("/raw-typed") .group("Index") .registerTyped({}, (_req, reply) => { // 构造时锁定 Raw=ExpressRaw:reply.raw 自动强类型,handler 零标注