From 234bab413fc9684edc675c4d4d1659b38cbad27e Mon Sep 17 00:00:00 2001 From: yourtion Date: Wed, 1 Jul 2026 00:31:50 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(leizmweb):=20reply.send=20=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E5=88=B0=20response.end=EF=BC=88=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=BA=AF=E6=96=87=E6=9C=AC=E5=93=8D=E5=BA=94=20404=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:LeizmWebAdapter.createLeiReply.send 调用 ctx.response.send(body), 但 @leizm/web 的 response 没有 send() 方法(仅有 end/json/file)。由于用了 可选链 leiRes.send?.(body),调用静默跳过 → 响应体未写 → 框架 final handler 返回 404。 影响面:在 @leizm/web adapter 下,任何用 ctx.reply.send(文本) 的路由 (register 或 registerTyped)都会 404。此前未暴露是因为现有测试与用例多走 reply.json(json 方法存在、工作正常)。 修复:send 映射到 response.end(body),并补 text/plain; charset=utf-8 content-type,与 json() 的 application/json 对齐。 复现测试(src/test/test-multilevel-route.ts):三框架对齐验证 forceGroup 下 「:param 多层路径 + 同前缀多路由」形态,覆盖 data/export/data:pk/refresh 四类路径(register 与 registerTyped 混合),确保三框架行为一致。 --- packages/erest-leizmweb/src/index.ts | 11 ++- src/test/test-multilevel-route.ts | 140 +++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 src/test/test-multilevel-route.ts diff --git a/packages/erest-leizmweb/src/index.ts b/packages/erest-leizmweb/src/index.ts index 6c24b59..fd5cf7a 100644 --- a/packages/erest-leizmweb/src/index.ts +++ b/packages/erest-leizmweb/src/index.ts @@ -133,12 +133,13 @@ export class LeizmWebAdapter implements FrameworkAdapter): Reply { const leiRes = (ctx.response ?? {}) as { status?: (c: number) => unknown; json?: (b: unknown) => void; - send?: (b: string) => void; + end?: (b?: string) => void; + setHeader?: (n: string, v: string) => void; }; const reply: Reply = { status(code: number) { @@ -148,8 +149,12 @@ function createLeiReply(ctx: Record): Reply { json(body: unknown) { leiRes.json?.(body); }, + // @leizm/web 的 response 没有 send() 方法(仅有 end/json/file),纯文本响应用 end()。 + // 此前误调不存在的 send()(可选链静默跳过)→ 响应体未写 → 框架 final handler 返回 404。 + // 修正:send 映射到 end(),并补 text/plain content-type,与 json() 的 application/json 对齐。 send(body: string) { - leiRes.send?.(body); + leiRes.setHeader?.("Content-Type", "text/plain; charset=utf-8"); + leiRes.end?.(body); }, // LeizmWebRaw 是 @leizm/web 的 Context(结构含 request/response/session 等), // 与此处的 Record 不充分重叠,需经 unknown 双重断言(同运行时桥接) diff --git a/src/test/test-multilevel-route.ts b/src/test/test-multilevel-route.ts new file mode 100644 index 0000000..46d2e66 --- /dev/null +++ b/src/test/test-multilevel-route.ts @@ -0,0 +1,140 @@ +/** + * @file 三框架对齐:forceGroup 模式下 `:param` 多层路径 + 同前缀多路由的路由匹配回归。 + * + * 背景:one-api 在 leizm/web adapter 下,同 group 注册 `/tables/:id/data`、 + * `/tables/:id/export`、`/tables/:id/data/:pk` 后,`/tables/:id/export`(非 typed .register()) + * 返回 404,而同路径改 .registerTyped() 后 200。erest 自身 leizmweb 集成测试只覆盖静态路径, + * 未覆盖 `:param` + 多层字面段 + 同前缀多路由的形态。本文件三框架对齐验证该场景。 + */ +import { describe, expect, it } from "vitest"; +import { Application, component, Router } from "@leizm/web"; +import express from "express"; +import Koa from "koa"; +import KoaRouter from "koa-router"; +import { expressAdapter, koaAdapter, leizmwebAdapter } from "./adapters"; +import { httpReq } from "./http-req"; +import lib from "./lib"; + +/** + * 在给定 apiService 上注册一组「`:param` 多层路径 + 同前缀多路由」的 API。 + * 模拟 one-api admin-data 路由形态: + * GET /items/:id/data (registerTyped) + * GET /items/:id/export (register 非typed) + * GET /items/:id/data/:pk (registerTyped) + * GET /items/:id/refresh (registerTyped,固定字面段) + */ +function registerMultilevelRoutes(apiService: ReturnType): void { + const g = apiService.group("api"); + g.get("/items/:id/data").registerTyped({ params: z.object({ id: z.string() }) }, (req, ctx) => { + ctx.reply.json({ data: req.params.id }); + }); + g.get("/items/:id/export").register((ctx) => { + ctx.reply.send("csv-content"); + }); + g.get("/items/:id/data/:pk").registerTyped({ params: z.object({ id: z.string(), pk: z.string() }) }, (req, ctx) => { + ctx.reply.json({ pk: req.params.pk }); + }); + g.get("/items/:id/refresh").registerTyped({ params: z.object({ id: z.string() }) }, (req, ctx) => { + ctx.reply.json({ refreshed: req.params.id }); + }); +} + +import { z } from "zod"; + +describe("多层级路径对齐 - Express", () => { + const apiService = lib({ + forceGroup: true, + info: { basePath: "" }, + groups: { api: { name: "api", prefix: "/api" } }, + }); + registerMultilevelRoutes(apiService); + const app = express(); + app.use(express.json()); + apiService.bind({ adapter: expressAdapter, app, router: express.Router }); + + it("GET /api/items/1/data → 200", async () => { + const res = await httpReq(app).get("/api/items/1/data"); + expect(res.status).toBe(200); + }); + it("GET /api/items/1/export(register 非typed)→ 200", async () => { + const res = await httpReq(app).get("/api/items/1/export"); + expect(res.status).toBe(200); + expect(res.text).toBe("csv-content"); + }); + it("GET /api/items/1/data/99 → 200", async () => { + const res = await httpReq(app).get("/api/items/1/data/99"); + expect(res.status).toBe(200); + }); + it("GET /api/items/1/refresh → 200", async () => { + const res = await httpReq(app).get("/api/items/1/refresh"); + expect(res.status).toBe(200); + }); +}); + +describe("多层级路径对齐 - @leizm/web", () => { + const apiService = lib({ + forceGroup: true, + info: { basePath: "" }, + groups: { api: { name: "api", prefix: "/api" } }, + }); + registerMultilevelRoutes(apiService); + const app = new Application(); + app.use("/", component.bodyParser.json()); + apiService.bind({ adapter: leizmwebAdapter, app, router: Router }); + + it("GET /api/items/1/data → 200", async () => { + const res = await httpReq(app.server).get("/api/items/1/data"); + expect(res.status).toBe(200); + }); + it("GET /api/items/1/export(register 非typed)→ 200", async () => { + const res = await httpReq(app.server).get("/api/items/1/export"); + expect(res.status).toBe(200); + expect(res.text).toBe("csv-content"); + }); + it("GET /api/items/1/data/99 → 200", async () => { + const res = await httpReq(app.server).get("/api/items/1/data/99"); + expect(res.status).toBe(200); + }); + it("GET /api/items/1/refresh → 200", async () => { + const res = await httpReq(app.server).get("/api/items/1/refresh"); + expect(res.status).toBe(200); + }); +}); + +describe("多层级路径对齐 - Koa", () => { + const apiService = lib({ + forceGroup: true, + info: { basePath: "" }, + groups: { api: { name: "api", prefix: "/api" } }, + }); + registerMultilevelRoutes(apiService); + const app = new Koa(); + app.use(async (ctx, next) => { + try { + await next(); + } catch (err: unknown) { + ctx.status = (err as { statusCode?: number }).statusCode ?? 500; + ctx.type = "application/json"; + ctx.body = JSON.stringify({ message: (err as Error).message }); + } + }); + apiService.bind({ adapter: koaAdapter, app, router: KoaRouter }); + + it("GET /api/items/1/data → 200", async () => { + const res = await httpReq(app.callback()).get("/api/items/1/data"); + expect(res.status).toBe(200); + }); + it("GET /api/items/1/export(register 非typed)→ 200", async () => { + const res = await httpReq(app.callback()).get("/api/items/1/export"); + expect(res.status).toBe(200); + expect(res.text).toBe("csv-content"); + }); + it("GET /api/items/1/data/99 → 200", async () => { + const res = await httpReq(app.callback()).get("/api/items/1/data/99"); + expect(res.status).toBe(200); + }); + it("GET /api/items/1/refresh → 200", async () => { + const res = await httpReq(app.callback()).get("/api/items/1/refresh"); + expect(res.status).toBe(200); + }); +}); From 144372256803fab07d0797bca1813dac18594c09 Mon Sep 17 00:00:00 2001 From: yourtion Date: Wed, 1 Jul 2026 00:35:50 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(envelope):=20Reply.markSent()=20?= =?UTF-8?q?=E9=80=83=E7=94=9F=E8=88=B1=EF=BC=8C=E6=89=8B=E5=8A=A8=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E4=B8=8D=E8=A2=AB=20enveloper=20=E4=BA=8C=E6=AC=A1?= =?UTF-8?q?=E5=8C=85=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:registerTyped handler 若需返回非 JSON 响应(CSV/文件下载/流式)并经 raw 手动写完响应,handler return 后 wrappedHandler 无条件置 __returned=true,wrapWithEnvelope 见 __returned 即调 successEnveloper → reply.json(),可能覆盖已发送的响应或触发 「headers already sent」类错误(三框架行为不一,靠框架兜底是脆弱巧合)。 修复:显式标记法(向后兼容,不破坏现有 __returned 机制) - Reply 接口新增公开方法 markSent():handler 手动写完响应后调用,告知 enveloper 跳过。 - wrapWithEnvelope 收紧决策:__returned && !reply.__sent 才包装。 - 三 adapter(express/koa/leizmweb)reply 实现统一维护 __sent 标记: json/send/markSent 均置 __sent=true,三框架行为对齐。 测试(test-envelope.ts,三框架对齐): - 单元:reply.__sent=true → enveloper 跳过(不调 successEnveloper/json)。 - 三框架集成:registerTyped + enveloper + raw 写 CSV + markSent → 响应体保持原始 CSV, 非 {success:true,data} 信封,content-type 为 text/csv。 非 breaking:markSent 为纯新增方法,不调它的现有 handler 行为不变。 --- packages/erest-express/src/index.ts | 10 +- packages/erest-koa/src/index.ts | 10 +- packages/erest-leizmweb/src/index.ts | 10 +- src/lib/adapters/types.ts | 10 ++ src/lib/adapters/utils.ts | 6 +- src/test/test-envelope.ts | 132 ++++++++++++++++++++++++++- 6 files changed, 167 insertions(+), 11 deletions(-) diff --git a/packages/erest-express/src/index.ts b/packages/erest-express/src/index.ts index c97dbff..4fb66e8 100644 --- a/packages/erest-express/src/index.ts +++ b/packages/erest-express/src/index.ts @@ -137,19 +137,25 @@ export class ExpressAdapter implements FrameworkAdapter { +function createExpressReply(req: unknown, res: unknown): Reply & { __sent: boolean } { const expressRes = res as { status?: (c: number) => unknown; json?: (b: unknown) => void; end?: (b: string) => void }; - const reply: Reply = { + const reply: Reply & { __sent: boolean } = { + __sent: false, status(code: number) { expressRes.status?.(code); return reply; }, json(body: unknown) { + reply.__sent = true; expressRes.json?.(body); }, send(body: string) { + reply.__sent = true; expressRes.end?.(body); }, + markSent() { + reply.__sent = true; + }, raw: { req, res } as ExpressRaw, }; return reply; diff --git a/packages/erest-koa/src/index.ts b/packages/erest-koa/src/index.ts index 186bbce..d2f366c 100644 --- a/packages/erest-koa/src/index.ts +++ b/packages/erest-koa/src/index.ts @@ -136,20 +136,26 @@ export class KoaAdapter implements FrameworkAdapter { } /** 构造 Koa 的 Reply 封装(写 ctx.status / ctx.body;含 raw 逃生舱:原生 ctx) */ -function createKoaReply(ctx: Record): Reply { +function createKoaReply(ctx: Record): Reply & { __sent: boolean } { const koaCtx = ctx as { status?: number; body?: unknown; type?: string }; - const reply: Reply = { + const reply: Reply & { __sent: boolean } = { + __sent: false, status(code: number) { koaCtx.status = code; return reply; }, json(body: unknown) { + reply.__sent = true; koaCtx.type = "application/json"; koaCtx.body = JSON.stringify(body); }, send(body: string) { + reply.__sent = true; koaCtx.body = body; }, + markSent() { + reply.__sent = true; + }, raw: ctx as KoaRaw, }; return reply; diff --git a/packages/erest-leizmweb/src/index.ts b/packages/erest-leizmweb/src/index.ts index fd5cf7a..bc8617b 100644 --- a/packages/erest-leizmweb/src/index.ts +++ b/packages/erest-leizmweb/src/index.ts @@ -134,28 +134,34 @@ export class LeizmWebAdapter implements FrameworkAdapter): Reply { +function createLeiReply(ctx: Record): Reply & { __sent: boolean } { const leiRes = (ctx.response ?? {}) as { status?: (c: number) => unknown; json?: (b: unknown) => void; end?: (b?: string) => void; setHeader?: (n: string, v: string) => void; }; - const reply: Reply = { + const reply: Reply & { __sent: boolean } = { + __sent: false, status(code: number) { leiRes.status?.(code); return reply; }, json(body: unknown) { + reply.__sent = true; leiRes.json?.(body); }, // @leizm/web 的 response 没有 send() 方法(仅有 end/json/file),纯文本响应用 end()。 // 此前误调不存在的 send()(可选链静默跳过)→ 响应体未写 → 框架 final handler 返回 404。 // 修正:send 映射到 end(),并补 text/plain content-type,与 json() 的 application/json 对齐。 send(body: string) { + reply.__sent = true; leiRes.setHeader?.("Content-Type", "text/plain; charset=utf-8"); leiRes.end?.(body); }, + markSent() { + reply.__sent = true; + }, // LeizmWebRaw 是 @leizm/web 的 Context(结构含 request/response/session 等), // 与此处的 Record 不充分重叠,需经 unknown 双重断言(同运行时桥接) raw: ctx as unknown as LeizmWebRaw, diff --git a/src/lib/adapters/types.ts b/src/lib/adapters/types.ts index 8b06b32..cba6c26 100644 --- a/src/lib/adapters/types.ts +++ b/src/lib/adapters/types.ts @@ -85,6 +85,11 @@ export type CheckerFunction = (erest: ERest, schema: API) => T; * `raw` 是逃生舱:当需要框架特有能力(setCookie/redirect/stream/文件下载等)时, * 通过 `reply.raw` 访问框架原生对象。类型由 `ERest` 的 Raw 泛型驱动, * 经子包 `createERest()` 工厂在构造时锁定。 + * + * `markSent()` 是 enveloper 逃生舱:当 registerTyped handler 需要返回非 JSON 响应 + * (CSV/文件下载/流式)并已通过 raw 手动写完响应时,调用它告知 enveloper 跳过自动 + * 包装。否则 enveloper 会在 handler return 后再用 successEnveloper 包一层 json, + * 可能覆盖已发送的响应或抛错(各框架行为不一)。 */ export interface Reply { /** 设置 HTTP 状态码并返回自身,支持链式调用 */ @@ -93,6 +98,11 @@ export interface Reply { json(body: unknown): void; /** 以纯文本写入响应体 */ send(body: string): void; + /** + * 标记响应已由 handler 手动发送(如经 raw 写 CSV/文件流),enveloper 将跳过自动包装。 + * 仅在注册了 enveloper 且 handler 已自行写完整响应时需要调用。 + */ + markSent(): void; /** 框架原生对象逃生舱(setCookie/redirect/stream/文件下载等) */ readonly raw: Raw; } diff --git a/src/lib/adapters/utils.ts b/src/lib/adapters/utils.ts index 494010a..3d4d992 100644 --- a/src/lib/adapters/utils.ts +++ b/src/lib/adapters/utils.ts @@ -96,7 +96,11 @@ export function wrapWithEnvelope( // 成功:handler 执行完无抛错。enveloper 模式下用 successEnveloper 包装 return 值。 // __returned 标记由 wrappedHandler 在 handler return 后置位(含 return undefined); // 未置位说明 handler 自行调 ctx.reply 写了响应(非 enveloper 用法),不再二次包装。 - if (successEnveloper && (ctx as Context & { __returned?: boolean }).__returned) { + // 额外检查 reply.__sent:handler 若调用了 reply.markSent()(或 json/send), + // 说明响应已手动发送(如经 raw 写 CSV/文件流),enveloper 不再二次包装, + // 避免覆盖已发送的响应或触发「headers already sent」类错误(各框架行为不一)。 + const reply = ctx.reply as { __sent?: boolean }; + if (successEnveloper && (ctx as Context & { __returned?: boolean }).__returned && !reply.__sent) { const returnValue = (ctx as Context & { __returnValue?: unknown }).__returnValue; const wrapped = successEnveloper(returnValue, ctx); ctx.reply.json(wrapped); diff --git a/src/test/test-envelope.ts b/src/test/test-envelope.ts index 22e1a97..bec3b3c 100644 --- a/src/test/test-envelope.ts +++ b/src/test/test-envelope.ts @@ -2,8 +2,11 @@ * @file 全局 response envelope 集成测试 * 验证 setResponseEnvelopers 后 registerTyped handler return data 被自动包装 */ +import { Application, component, Router } from "@leizm/web"; import express from "express"; -import { expressAdapter } from "./adapters"; +import Koa from "koa"; +import KoaRouter from "koa-router"; +import { expressAdapter, koaAdapter, leizmwebAdapter } from "./adapters"; import { httpReq as request } from "./http-req"; import { afterAll, describe, expect, it, vi } from "vitest"; import { z } from "zod"; @@ -11,7 +14,7 @@ import { wrapWithEnvelope } from "../lib/adapters/utils.js"; import type { Context } from "../lib/adapters/types.js"; import lib from "./lib"; -/** 构造最小 Context mock(state 可读写,reply 记录调用) */ +/** 构造最小 Context mock(state 可读写,reply 记录调用;__sent 模拟手动响应标记) */ function mockCtx(): Context & { __returned?: boolean; __returnValue?: unknown; @@ -19,14 +22,24 @@ function mockCtx(): Context & { status: ReturnType; json: ReturnType; send: ReturnType; + markSent: ReturnType; raw: unknown; + __sent?: boolean; }; } { const reply = { status: vi.fn(() => reply), - json: vi.fn(), - send: vi.fn(), + json: vi.fn(() => { + reply.__sent = true; + }), + send: vi.fn(() => { + reply.__sent = true; + }), + markSent: vi.fn(() => { + reply.__sent = true; + }), raw: {}, + __sent: false, }; return { method: "GET", @@ -220,6 +233,117 @@ describe("wrapWithEnvelope 单元测试", () => { expect(success).toHaveBeenCalledWith(undefined, ctx); expect(ctx.reply.json).toHaveBeenCalled(); }); + + it("reply.__sent=true(handler 调 markSent 手动响应)→ enveloper 跳过,不二次包装", async () => { + const ctx = mockCtx(); + ctx.__returned = true; + ctx.__returnValue = { raw: "csv" }; + ctx.reply.__sent = true; // 模拟 handler 已调 reply.markSent() + const success = vi.fn(); + + await wrapWithEnvelope(async () => Promise.resolve(), { success })(ctx); + + expect(success).not.toHaveBeenCalled(); + expect(ctx.reply.json).not.toHaveBeenCalled(); + }); +}); + +// 三框架集成:enveloper 模式下 registerTyped handler 经 raw 手动写 CSV + reply.markSent(), +// enveloper 应跳过自动包装,响应体保持原始 CSV(而非被 json 覆盖/报错)。 +const csvEnvelopers = { + success: (data: unknown) => ({ success: true, data }), + error: (err: unknown) => { + const e = err as { statusCode?: number; message?: string }; + return { body: { error: e.message ?? "fail" }, status: e.statusCode ?? 500 }; + }, +}; + +describe("enveloper + markSent 逃生舱 - Express", () => { + const apiService = lib({ + forceGroup: true, + info: { basePath: "" }, + groups: { api: { name: "api", prefix: "/api" } }, + }); + apiService.setResponseEnvelopers(csvEnvelopers); + apiService + .group("api") + .get("/csv") + .registerTyped({}, (req, ctx) => { + // 经 raw 手动写非 JSON 响应(CSV),调 markSent 告知 enveloper 跳过 + const res = (ctx.reply.raw as { res: { setHeader: (n: string, v: string) => void; end: (b: string) => void } }) + .res; + res.setHeader("Content-Type", "text/csv"); + res.end("a,b\n1,2"); + ctx.reply.markSent(); + }); + const app = express(); + app.use(express.json()); + apiService.bind({ adapter: expressAdapter, app, router: express.Router }); + + it("raw 写 CSV + markSent → 响应体是原始 CSV,非信封", async () => { + const res = await request(app).get("/api/csv"); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/csv"); + expect(res.text).toBe("a,b\n1,2"); + }); +}); + +describe("enveloper + markSent 逃生舱 - @leizm/web", () => { + const apiService = lib({ + forceGroup: true, + info: { basePath: "" }, + groups: { api: { name: "api", prefix: "/api" } }, + }); + apiService.setResponseEnvelopers(csvEnvelopers); + apiService + .group("api") + .get("/csv") + .registerTyped({}, (req, ctx) => { + const raw = ctx.reply.raw as { + response: { setHeader: (n: string, v: string) => void; end: (b: string) => void }; + }; + raw.response.setHeader("Content-Type", "text/csv"); + raw.response.end("a,b\n1,2"); + ctx.reply.markSent(); + }); + const app = new Application(); + app.use("/", component.bodyParser.json()); + apiService.bind({ adapter: leizmwebAdapter, app, router: Router }); + + it("raw 写 CSV + markSent → 响应体是原始 CSV,非信封", async () => { + const res = await request(app.server).get("/api/csv"); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/csv"); + expect(res.text).toBe("a,b\n1,2"); + }); +}); + +describe("enveloper + markSent 逃生舱 - Koa", () => { + const apiService = lib({ + forceGroup: true, + info: { basePath: "" }, + groups: { api: { name: "api", prefix: "/api" } }, + }); + apiService.setResponseEnvelopers(csvEnvelopers); + apiService + .group("api") + .get("/csv") + .registerTyped({}, (req, ctx) => { + // Koa:用原生 ctx 赋值写响应(Koa 惯例:ctx.type + ctx.body 均为 setter),markSent 阻止 enveloper 覆盖 + const raw = ctx.reply.raw as { type: string; body: unknown }; + raw.type = "text/csv"; + raw.body = "a,b\n1,2"; + ctx.reply.markSent(); + }); + const app = new Koa(); + apiService.bind({ adapter: koaAdapter, app, router: KoaRouter }); + + it("raw 写 CSV + markSent → 响应体是原始 CSV,非信封", async () => { + const res = await request(app.callback()).get("/api/csv"); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/csv"); + expect(res.text).toBe("a,b\n1,2"); + }); }); afterAll(() => {}); From 0cf37c0bd037fb50698b30e98137e6ec648f7fc6 Mon Sep 17 00:00:00 2001 From: yourtion Date: Wed, 1 Jul 2026 00:38:04 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20=E6=96=87=E6=A1=A3=E5=8C=96=20DELET?= =?UTF-8?q?E=20input=20=E5=BD=92=E5=B1=9E=20+=20markSent=20=E9=80=83?= =?UTF-8?q?=E7=94=9F=E8=88=B1=20+=20leizmweb=20send=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README 测试章节:显式说明 .input() 对 GET/DELETE 归 query、POST/PUT/PATCH 归 body, 补 DELETE 走 query schema 的示例与警示。 - README raw 逃生舱章节:新增 markSent() 小节,说明 enveloper 模式下手动写非 JSON 响应(CSV/下载/流)后须调 markSent 跳过自动包装,含三框架示例。 - agent.ts .input() JSDoc:补充方法归属约定说明。 - MIGRATION.md:新增 v3.2.1 章节,登记 markSent(新增)、leizmweb send 修复、 DELETE input 文档化三项。 --- MIGRATION.md | 15 +++++++++++++++ README.md | 35 +++++++++++++++++++++++++++++++++++ src/lib/agent.ts | 12 +++++++++++- 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/MIGRATION.md b/MIGRATION.md index 986b27a..b38ada8 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -319,3 +319,18 @@ instead」)。`@koa/router` 是官方维护的继任包,API 与 koa-router * - **`z.anyObject()`**:等价 `z.object({}).catchall(z.unknown())`,挂在 erest 导出的 `z` 上(也具名导出 `zAnyObject` 常量)。供「动态字段 body」场景使用。 - **`success()`**:TestAgent 测试方法泛型化,返回类型可从 response schema 推导(`.get(path).success()`);默认 `unknown` 向后兼容。 - **`setResponseEnvelopers({ success, error, testUnwrapper })`**:注册后 registerTyped handler 进入「return 模式」——handler 只 `return data`,框架用 success enveloper 自动包装成响应体;抛错用 error enveloper 包装成 `{body, status}`。未注册时维持 v3.1 行为(handler 调 `ctx.reply` 写响应)。`testUnwrapper` 供测试脚手架拆信封(便捷入口,等价 `setFormatOutput`)。 + + +## v3.2.1 — enveloper 逃生舱 + @leizm/web send 修复 + 测试文档 + +### 新增(非 breaking) + +- **`Reply.markSent()`**:enveloper 模式下,`registerTyped` handler 若需返回非 JSON 响应(CSV / 文件下载 / 流式)并经 `ctx.reply.raw` 手动写完响应,调用 `ctx.reply.markSent()` 告知 enveloper 跳过自动包装。否则 enveloper 会在 handler return 后再用 `successEnveloper` 包一层 `reply.json()`,可能覆盖已发送响应或触发「headers already sent」(各框架行为不一)。三 adapter(express/koa/leizmweb)的 `reply` 实现统一维护 `__sent` 标记:`json()`/`send()`/`markSent()` 均置位,`wrapWithEnvelope` 综合判断。不调 `markSent` 的存量 handler 行为不变。 + +### 修复 + +- **`@erest/leizmweb` `reply.send` 映射错误导致纯文本响应 404**:`createLeiReply.send` 此前调 `ctx.response.send()`,但 `@leizm/web` 的 response 没有 `send` 方法(仅有 `end/json/file`)。可选链 `leiRes.send?.()` 静默跳过 → 响应体未写 → 框架 final handler 返回 404。修正为映射到 `end(body)` + 补 `text/plain` content-type。**影响面**:`@leizm/web` adapter 下任何用 `ctx.reply.send(文本)` 的路由此前都会 404(用 `reply.json` 的路由不受影响)。 + +### 文档 + +- **测试引擎 `.input()` 的方法归属约定显式化**:GET/DELETE 归 query、POST/PUT/PATCH 归 body。注册 DELETE 路由时 schema 必须从 `query` 读参数(HTTP DELETE body 有争议,fetch/浏览器/代理普遍不传递)。README 与 `agent.ts` JSDoc 同步补充。 diff --git a/README.md b/README.md index e415c7b..ac5827c 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,19 @@ api.genDocs('./docs/'); > `.input(data)` 对 **GET/DELETE** 把 `data` 转为 query string,对 **POST/PUT/PATCH** 作为 JSON body。 > 用 `.query(data)` / `.json(data)` 可显式指定来源。 +> **⚠️ DELETE 参数从 query 读**:HTTP DELETE 带 body 有争议(fetch/浏览器/代理普遍不保证传递), +> 所以测试引擎把 DELETE 的 `.input()` 归为 query,**生产侧三 adapter 也只从 query 读 DELETE 参数**。 +> 因此注册 DELETE 路由时,schema 必须用 `query`(不是 `body`): + +```typescript +// ✅ DELETE 的确认参数走 query +api.delete('/items/:id').registerTyped( + { params: z.object({ id: z.string() }), query: z.object({ confirm: z.boolean() }) }, + (req) => { if (!req.query.confirm) throw new Error('需确认'); /* ... */ }, +); +// 测试侧对应:api.test.delete('/items/1').input({ confirm: true }) +``` + ```typescript // initTest 接收 http.Server 或框架的 app/server/callback api.initTest(app); @@ -471,6 +484,28 @@ api.api.post('/login').group('auth').title('登录').registerTyped( > ⚠️ `raw` 的头部/cookie 操作应在 `ctx.reply.json()`/`ctx.reply.send()` **之前**调用(HTTP 头先于体发送)。`reply.raw` 是逃生舱,不参与框架无关复用——用了 raw 的 handler 即与具体框架耦合。 +#### `ctx.reply.markSent()`:enveloper 模式下的非 JSON 响应逃生舱 + +当注册了 `setResponseEnvelopers` 且 `registerTyped` handler 需要返回**非 JSON 响应**(CSV / 文件下载 / 流式)时,handler 必须经 `reply.raw` 手动写完整响应,并调用 **`ctx.reply.markSent()`** 告知 enveloper 跳过自动包装。 + +否则 handler return 后,enveloper 会用 `successEnveloper` 包一层 `reply.json()`,可能覆盖已发送的响应或触发「headers already sent」类错误(各框架行为不一)。 + +```typescript +api.group('admin').get('/tables/:id/export').registerTyped( + { params: z.object({ id: z.string() }) }, + (req, ctx) => { + // 经 raw 手动写 CSV(非 JSON),三框架原生写法不同 + const res = ctx.reply.raw.res; // Express;Koa 用 ctx.reply.raw.body,@leizm/web 用 ctx.reply.raw.response + res.setHeader('Content-Type', 'text/csv'); + res.end('a,b\n1,2'); + ctx.reply.markSent(); // ← 关键:跳过 enveloper 的自动包装 + }, +); +``` + +> `markSent()` 只在「enveloper + registerTyped + 手动 raw 响应」三者同时成立时需要。 +> 用 `register()`(非 typed)注册的 handler 本就不触发 enveloper,无需调用。 + ## 参数读取:`$params` 与分层访问器 adapter 的 checker 把校验后的参数注入到 erest 的标准化 `ctx` 上,handler 通过它们读取。 diff --git a/src/lib/agent.ts b/src/lib/agent.ts index d24002d..42c470f 100644 --- a/src/lib/agent.ts +++ b/src/lib/agent.ts @@ -152,7 +152,17 @@ export class TestAgent { return this; } - /** 添加输入参数 */ + /** + * 添加输入参数。 + * + * 方法归属约定(HTTP 语义 + 浏览器/fetch 兼容): + * - GET / DELETE:归为 query(URL ?k=v)。HTTP DELETE 带 body 有争议, + * fetch/浏览器/代理普遍不保证传递 DELETE body,故统一走 query。 + * - POST / PUT / PATCH:归为 body(JSON)。 + * + * 因此注册 DELETE 路由时,schema 应从 query 读取参数(非 body): + * api.delete('/items/:id').registerTyped({ query: z.object({ confirm: z.boolean() }) }, ...) + */ public input(data: Record) { this.debug("input: %j", data); Object.assign(this.options.agentInput, data); From bb9835a66fa3bbe9be12123279bba6bb918c5569 Mon Sep 17 00:00:00 2001 From: yourtion Date: Wed, 1 Jul 2026 08:06:46 +0800 Subject: [PATCH 4/4] =?UTF-8?q?refactor(deps):=20zod=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=20peerDependencies=EF=BC=88=E6=B6=88=E9=99=A4=20link=20?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E7=9A=84=20zod=20=E5=89=AF=E6=9C=AC=E5=86=B2?= =?UTF-8?q?=E7=AA=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:erest 此前把 zod@^4.0.5 列为 hard dependency。任何通过 link/本地路径依赖 erest 的项目会出现两份 zod 副本(erest 自带 + 宿主自带),TS 因 zod 内部版本 字面量标记(_zod.version.minor)不同而报类型不兼容(如 4.4.3 vs 4.0.5)。 修复:zod 从 dependencies 移到 peerDependencies(^4.0.0,required), 由宿主项目统一提供版本——这是 schema 校验库的标准做法。devDependencies 保留 zod@^4.4.3 供本仓库开发/测试用。 Breaking:宿主项目须显式声明 zod 依赖。MIGRATION.md v3.2.1 登记迁移说明。 --- MIGRATION.md | 4 ++++ package.json | 14 ++++++++++---- pnpm-lock.yaml | 11 ++++++++--- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index b38ada8..df83db6 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -334,3 +334,7 @@ instead」)。`@koa/router` 是官方维护的继任包,API 与 koa-router * ### 文档 - **测试引擎 `.input()` 的方法归属约定显式化**:GET/DELETE 归 query、POST/PUT/PATCH 归 body。注册 DELETE 路由时 schema 必须从 `query` 读参数(HTTP DELETE body 有争议,fetch/浏览器/代理普遍不传递)。README 与 `agent.ts` JSDoc 同步补充。 + +### Breaking + +- **`zod` 从 `dependencies` 改为 `peerDependencies`**:作为 schema 校验库,erest 不应硬钉死 zod 版本,应由宿主项目统一提供。此前 erest 把 `zod@^4.0.5` 列为 hard dependency,导致任何 link/依赖 erest 的项目出现两份 zod 副本(erest 自带的 + 宿主的),TS 因 zod 版本字面量标记不同而报类型不兼容。**迁移**:宿主项目须在自身 `dependencies` 显式声明 `zod`(版本 `^4.0.0`)。adapter 子包(`@erest/express` 等)不直接依赖 zod,无需改动。 diff --git a/package.json b/package.json index 6e2d496..358af8f 100644 --- a/package.json +++ b/package.json @@ -58,11 +58,16 @@ "homepage": "https://github.com/yourtion/node-erest#readme", "dependencies": { "debug": "^4.4.1", - "path-to-regexp": "^6.3.0", - "zod": "^4.0.5" + "path-to-regexp": "^6.3.0" }, "peerDependencies": { - "@types/node": "*" + "@types/node": "*", + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": false + } }, "devDependencies": { "@leizm/web": "^2.7.3", @@ -85,7 +90,8 @@ "oxlint": "1.71.0", "typedoc": "^0.28.19", "typescript": "^5.8.3", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "zod": "^4.4.3" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65ff81e..618e9c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: path-to-regexp: specifier: ^6.3.0 version: 6.3.0 - zod: - specifier: ^4.0.5 - version: 4.0.5 devDependencies: '@leizm/web': specifier: ^2.7.3 @@ -81,6 +78,9 @@ importers: vitest: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.15)(@vitest/ui@3.2.4)(yaml@2.9.0) + zod: + specifier: ^4.4.3 + version: 4.4.3 examples: dependencies: @@ -2051,6 +2051,9 @@ packages: zod@4.0.5: resolution: {integrity: sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@ampproject/remapping@2.3.0': @@ -3851,3 +3854,5 @@ snapshots: ylru@1.4.0: {} zod@4.0.5: {} + + zod@4.4.3: {}