@springbrand/http 0.1.0-alpha.1 → 0.1.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,12 @@
1
1
  ---
2
2
  name: dune-react-http
3
- description: Use SpringBrand shared HTTP contracts, Axios request clients, Query error recovery, and server response helpers. Trigger when constructing requests, injecting tokens, handling HTTP failures, connecting TanStack Query, or creating standard server responses.
3
+ description: Use SpringBrand shared HTTP contracts, Axios request clients, Query error recovery, project error catalogs, Hono responses, and server helpers. Trigger when constructing requests, injecting tokens, handling HTTP failures, connecting TanStack Query, defining error codes, returning Hono/OpenAPI responses, reading request bodies, or creating standard server responses.
4
4
  ---
5
5
 
6
6
  # Dune React HTTP
7
7
 
8
+ Shared error codes, canonical statuses and their HTTP statuses are defined in `@springbrand/errors`; this package reads them and does not restate them. `@springbrand/errors` is a peer dependency accepting any version: the consumer installs it directly and its version is the one HTTP uses.
9
+
8
10
  Read the installed declarations and README before configuring a client. Public entry points own different runtime needs; import only the capability needed.
9
11
 
10
12
  | Entry point | Responsibility |
@@ -13,15 +15,18 @@ Read the installed declarations and README before configuring a client. Public e
13
15
  | @springbrand/http/errors | Error definitions, cancellation and transient failure classification |
14
16
  | @springbrand/http/client | Axios client and standard HTTP error parsing |
15
17
  | @springbrand/http/query | Optional TanStack React Query integration |
16
- | @springbrand/http/server | Standard Response helpers and service authentication |
18
+ | @springbrand/http/catalog | Project error catalog: typed HttpError, Zod response schemas, client decoder (zod peer). Codes registered in `@springbrand/errors` must use the registered HTTP status |
19
+ | @springbrand/http/hono | Hono responders with TypedResponse types for zod-openapi (hono peer) |
20
+ | @springbrand/http/types | Type-only entry for every public type, so consumers emitting declarations stay portable |
21
+ | @springbrand/http/server | Standard Response helpers, bounded body reading, If-Match, Content-Disposition, log diagnostics and service authentication |
17
22
 
18
23
  ## Client setup
19
24
 
20
- Create one client per trusted API origin. Inject getToken; do not copy Token storage or Bearer construction into requests. Relative paths include the configured base path. Foreign origins and URL credentials are rejected. Defaults are Cookie omit, 15-second deadline and 10-MiB response limit.
25
+ Create one client per trusted API origin. Inject getToken; do not copy Token storage or Bearer construction into requests. Relative paths include the configured base path. Foreign origins and URL credentials are rejected. Defaults are Cookie omit, no-store cache, manual redirects, 15-second deadline and 10-MiB response limit. The Fetch adapter supports browsers, Node.js 24 and Cloudflare Workers. Redirects reject with REQUEST_REDIRECT_BLOCKED, including opaque browser redirects; never enable automatic redirects for credential-bearing API calls.
21
26
 
22
27
  Pass params for query serialization and signal/timeoutMs for cancellation/deadlines. Null/undefined query values are omitted; use a string for literal null. Do not duplicate query encoding or timeout controllers in domain clients.
23
28
 
24
- Use get/post/put/patch/delete for payloads, request for data plus status and headers. Explicitly choose json, text or blob for endpoints without the standard success envelope. FormData owns its boundary. Pass Query's signal through; do not wrap cancellation as network failure.
29
+ Use get/post/put/patch/delete for payloads, request for data plus status and headers. Explicitly choose json, text or blob for endpoints without the standard success envelope. FormData owns its boundary. In a browser, a request with onUploadProgress goes through XMLHttpRequest, because fetch upload progress needs a streamed body that Chrome sends only over HTTP/2; a redirect it followed still rejects with REQUEST_REDIRECT_BLOCKED. The client sends no User-Agent of its own (Safari and Firefox would otherwise require every cross-origin API to allow it in CORS); set one explicitly only for a server-to-server call that needs it. Pass Query's signal through; do not wrap cancellation as network failure.
25
30
 
26
31
  ## Error handling
27
32
 
@@ -31,14 +36,32 @@ Choose one feedback owner: client for direct calls, query for Query/Mutation. In
31
36
 
32
37
  Use isTransientHttpError from the core or errors entry point for cached-view recovery, matching the Query retry classifier. Network, timeout and HTTP 429/502/503/504 are transient; cancellation, decoding, auth and permission errors are not. Classification never authorizes replaying writes.
33
38
 
39
+ Runtime capability failures use RUNTIME_UNSUPPORTED (kind runtime), distinct from invalid input; neither runtime failures nor blocked redirects are transient or retryable. Raw runtime diagnostics are not exposed.
40
+
41
+ Override SDK-generated copy with the client messages option when the product language is not Chinese; server errMsg is unaffected.
42
+
43
+ Return a Promise from onUnauthorized when it rechecks the session, so later failures of the same token are handled again after the check settles.
44
+
45
+ Session-expiry failures (isUnauthorized) never reach notify, with or without a token; control per-request presentation with feedback.
46
+
34
47
  Only confirmed current Bearer session failures trigger onUnauthorized. Never clear sessions on network failures, every 401, or 403. SDK callbacks guard stale Token responses and duplicate feedback. Cookie login/recovery belongs to consumer authentication.
35
48
 
49
+ ## Error catalog
50
+
51
+ Business error codes belong to each project. Define them once with defineErrorCatalog and derive HttpError, OpenAPI schemas and the client decodeError from it; do not keep a second catalog, HttpError class or error schema in the project. The catalog must define the SDK's required codes (the type checker reports missing ones). Status 0 codes are client-only and cannot be thrown. Register a details schema per code; unregistered details are never exposed.
52
+
53
+ Use parseInput only for request input. Normalize thrown errors through toHttpError in the app's error handler; unknown exceptions return undefined and must become a generic 500.
54
+
55
+ ## Hono
56
+
57
+ Create responders once with createHonoHttp(catalog) and return them directly from handlers so zod-openapi checks declared statuses. Do not cast their results or rebuild envelopes with c.json. httpError records the error on c.get("apiError") for request logging.
58
+
36
59
  ## Server setup
37
60
 
38
61
  Return ok or fail through framework-compatible Response handling. Failures use real 4xx/5xx and public error messages. Keep domain errors separate until the HTTP handler translates them. Validate business details before returning them. toErrorResponse hides unknown exceptions.
39
62
 
40
- Use service request/verification helpers for service credentials, independently of user authentication. Never forward user Cookie/Authorization or send service tokens to another origin.
63
+ Read untrusted bodies with readJsonLimited/readBytesLimited/limitRequestBody instead of trusting Content-Length. Log errors through errorDiagnostics, never by serializing raw errors. Use service request/verification helpers for service credentials, independently of user authentication. Service requests use manual redirects and no-store; Workers omit the unsupported credentials option. The caller must reject 3xx instead of following them with service credentials. Never forward user Cookie or send service tokens to another origin. Forward the caller's Authorization only with an explicit `{ forwardAuthorization: true }` when the downstream service authorizes that same user.
41
64
 
42
65
  ## Verification
43
66
 
44
- Exercise transient read recovery, write uncertainty, field errors, cancellation, Token changes and callback failures. Verify server imports without React Query and packaged public entry points. Use HTTP Storybook examples as behavior references, not copied implementations. Do not add app-local parallel clients or edit installed node_modules.
67
+ Exercise transient read recovery, write uncertainty, field errors, cancellation, Token changes and callback failures. Run the Miniflare/workerd regression with mocked outbound services; Node-only Fetch tests do not prove Worker compatibility. Verify server imports without React Query and packaged public entry points. Use HTTP Storybook examples as behavior references, not copied implementations. Do not add app-local parallel clients or edit installed node_modules.
package/README.md CHANGED
@@ -1,13 +1,15 @@
1
1
  # SpringBrand HTTP SDK
2
2
 
3
- 一个包提供共享 HTTP 合同、Axios 客户端、Query 接入和框架无关的服务端辅助函数。错误规则由 SDK 维护,消费者提供 Token、通知和业务回调。
3
+ 一个包提供共享 HTTP 合同、Axios 客户端、Query 接入、错误码目录、Hono 接入和框架无关的服务端辅助函数。错误规则由 SDK 维护,消费者提供 Token、通知和业务回调。
4
4
 
5
5
  ## 安装
6
6
 
7
7
  ```sh
8
- pnpm add @springbrand/http@alpha
8
+ pnpm add @springbrand/http@alpha @springbrand/errors@alpha
9
9
  ```
10
10
 
11
+ `@springbrand/errors` 是 peer 依赖,接受任意版本:由使用方安装并决定版本,HTTP 包直接使用它,不需要为 errors 更新而重新发布。
12
+
11
13
  使用 Query 入口时,另外安装兼容的 `@tanstack/react-query`。
12
14
 
13
15
  ## 入口
@@ -18,9 +20,12 @@ pnpm add @springbrand/http@alpha
18
20
  | `@springbrand/http/errors` | 基础错误码、取消识别和临时错误判断 |
19
21
  | `@springbrand/http/client` | createHttpClient、parseHttpError |
20
22
  | `@springbrand/http/query` | createHttpQueryClient、读取重试策略 |
21
- | `@springbrand/http/server` | ok、fail、toErrorResponse、服务鉴权与安全诊断 |
23
+ | `@springbrand/http/catalog` | defineErrorCatalog:项目错误码目录、HttpError、Zod Schema、客户端解码 |
24
+ | `@springbrand/http/hono` | createHonoHttp:ok、httpError 等带 TypedResponse 类型的 Hono 响应 |
25
+ | `@springbrand/http/types` | 只含类型的入口:公共 API 用到的全部类型,供开启 declaration 的使用方生成可移植的声明文件 |
26
+ | `@springbrand/http/server` | ok、fail、toErrorResponse、请求体限长读取、If-Match、Content-Disposition、服务鉴权与日志诊断 |
22
27
 
23
- 客户端依赖 Axios。Query 是独立入口,以 `@tanstack/react-query` 作为可选 peer;核心和服务端使用者无需安装 React 或 Query。服务端要求标准 Request、Response 和 Web Crypto,建议 Node.js 24 或兼容运行时。
28
+ 客户端依赖 Axios。Query、catalog、hono 是独立入口,分别以 `@tanstack/react-query`、`zod`、`hono` 作为可选 peer;只用到哪个入口才需要安装对应依赖,核心和 server 入口都不需要。服务端要求标准 Request、Response 和 Web Crypto,建议 Node.js 24 或兼容运行时。
24
29
 
25
30
  ## 请求实例
26
31
 
@@ -39,7 +44,7 @@ const items = await api.get<Item[]>('/items');
39
44
  await api.post('/items', { name: 'Example' });
40
45
  ```
41
46
 
42
- Token 每次发送时读取。实例只向绑定来源发送请求;非法地址、带用户名密码的 URL 和其他来源的绝对 URL 会在发送前失败。`baseUrl` 的路径前缀会与相对接口路径拼接。
47
+ Token 每次发送时读取。实例使用 `cache: no-store` 和 `redirect: manual`,兼容浏览器、Node.js 24 和 Cloudflare Workers。API 重定向(含浏览器 opaque redirect)以 REQUEST_REDIRECT_BLOCKED 拒绝,不跟随跳转;Query 缓存仍由 Query 管理。实例只向绑定来源发送请求;非法地址、带用户名密码的 URL 和其他来源的绝对 URL 会在发送前失败。`baseUrl` 的路径前缀会与相对接口路径拼接。
43
48
 
44
49
  客户端使用 Axios Fetch Adapter,默认 `credentials: 'omit'`。Cookie 登录使用显式 `credentials: 'include'` 的独立实例。`auth: false` 关闭 SDK 的 Bearer 注入,Cookie 策略不随之改变。有 getToken 时,SDK 管理 Authorization,额外请求头不能覆盖它。
45
50
 
@@ -54,7 +59,7 @@ const file = await api.request<Blob>('/download', { responseMode: 'blob' });
54
59
  const filenameHeader = file.headers['content-disposition'];
55
60
  ```
56
61
 
57
- 普通 JSON、文本和 Blob 分别使用 json、text、blob 模式。GET 和 HEAD 不接受请求体。FormData 不手工设置 multipart boundary。
62
+ 普通 JSON、文本和 Blob 分别使用 json、text、blob 模式。GET 和 HEAD 不接受请求体。FormData 不手工设置 multipart boundary。浏览器里传了 onUploadProgress 的请求改用 XMLHttpRequest 发送:fetch 要报上传进度只能流式发送请求体,Chrome 只在 HTTP/2 上允许,HTTP/1.1 会报 ERR_ALPN_NEGOTIATION_FAILED。XMLHttpRequest 自动跟随的重定向同样以 REQUEST_REDIRECT_BLOCKED 拒绝。
58
63
 
59
64
  默认超时 15 秒,覆盖 timeoutMs,0 表示关闭超时。默认最大响应 10 MiB,覆盖 maxResponseBytes;0 仅允许空内容。客户端在读取数据流时检查实际字节数,超限取消读取。第一版不暴露无限流、SSE 或原生 Response 合同。
60
65
 
@@ -119,9 +124,17 @@ if (query.data !== undefined && isTransientHttpError(query.error)) {
119
124
 
120
125
  查询参数直接传 `params`,由 SDK 编码:数组发送重复参数,null 和 undefined 省略。需要字面值 null 的业务字段应传字符串 `'null'`。传入 `signal` 和 `timeoutMs` 即可取消或设置超时,无需在业务包再创建超时控制器。
121
126
 
122
- 主动写入的网络失败会提示结果暂时无法确认,不自动重发。取消保持取消语义,不包装成 NETWORK_ERROR。超时为 REQUEST_TIMEOUT,格式错误为 RESPONSE_INVALID,内容超限为 RESPONSE_TOO_LARGE。
127
+ 主动写入的网络失败会提示结果暂时无法确认,不自动重发。取消保持取消语义,不包装成 NETWORK_ERROR。超时为 REQUEST_TIMEOUT,格式错误为 RESPONSE_INVALID,内容超限为 RESPONSE_TOO_LARGE。请求运行时不支持所需能力时为 RUNTIME_UNSUPPORTED(kind: runtime),与非法调用参数 REQUEST_INVALID_INPUT 分开;运行时和重定向错误不重试、不清理登录。
128
+
129
+ 默认仅 401 且 AUTH_UNAUTHENTICATED 触发失效回调;这类登录失效交给认证流程,不论请求是否携带 Token 都不弹通用提示。单个请求的提示方式用 `feedback`(auto、inline、silent)控制。onUnauthorized 返回 Promise 时(例如复检会话),复检期间同一 Token 的重复失效只处理一次,Promise 完成后恢复处理;返回 void 时直到 Token 变化。旧身份的响应不清理新身份,同一 Token 并发失效仅处理一次;网络错误、其他 401 和 403 不退出登录。默认自动处理针对实例携带的 Bearer 身份,Cookie 会话的恢复流程由消费者掌握。
123
130
 
124
- 默认仅 401 且 AUTH_UNAUTHENTICATED 触发失效回调。旧身份的响应不清理新身份,同一 Token 并发失效仅处理一次;网络错误、其他 401 和 403 不退出登录。默认自动处理针对实例携带的 Bearer 身份,Cookie 会话的恢复流程由消费者掌握。
131
+ `messages` 用来替换 SDK 自身产生的提示文案(例如 NETWORK_ERROR、REQUEST_TIMEOUT,以及写操作结果无法确认时的 MUTATION_RESULT_UNCERTAIN),默认是中文。服务端返回的 errMsg 不受影响。
132
+
133
+ ```ts
134
+ createHttpClient({ baseUrl, messages: { NETWORK_ERROR: 'Unable to reach the server', MUTATION_RESULT_UNCERTAIN: 'Check the result before retrying' } });
135
+ ```
136
+
137
+ pnpm 可能按消费者的可选 peer 解析出多份 SDK 实例;`instanceof ApiError` 通过全局 Symbol 跨实例识别,目录的 HttpError 等子类仍按原型链判断。
125
138
 
126
139
  ApiError 使用对象参数,泛型 details 可用于业务详情:
127
140
 
@@ -136,6 +149,65 @@ throw new ApiError<{ revision: number }>({
136
149
 
137
150
  decodeResponse 处理特殊成功协议;decodeError 可调用公开 parseHttpError 后验证并附加业务 details。SDK 保留请求上下文、错误类和原始状态,不在公共错误中保存原始 Axios config 或凭据。
138
151
 
152
+ ## 错误码目录
153
+
154
+ 业务错误码属于项目,不属于 SDK。每个项目用 `defineErrorCatalog` 定义一次目录,服务端抛错、OpenAPI Schema 和浏览器解析都从这里派生:
155
+
156
+ ```ts
157
+ import { z } from 'zod';
158
+ import { defineErrorCatalog } from '@springbrand/http/catalog';
159
+
160
+ export const errorDefinitions = {
161
+ // SDK 服务端辅助函数会产生这些码,目录必须定义(类型检查会提示缺失)。
162
+ REQUEST_INVALID_INPUT: { status: 400, message: 'Request input is invalid' },
163
+ REQUEST_INVALID_JSON: { status: 400, message: 'Expected a JSON object' },
164
+ REQUEST_VALIDATION_FAILED: { status: 422, message: 'Validation failed' },
165
+ REQUEST_PAYLOAD_TOO_LARGE: { status: 413, message: 'Request body is too large' },
166
+ REQUEST_NOT_FOUND: { status: 404, message: 'Resource not found' },
167
+ REQUEST_RATE_LIMITED: { status: 429, message: 'Too many requests' },
168
+ AUTH_UNAUTHENTICATED: { status: 401, message: 'Authentication required' },
169
+ INTERNAL_ERROR: { status: 500, message: 'Internal server error' },
170
+ // 业务码
171
+ CAMPAIGN_NOT_STARTED: { status: 409, message: 'Campaign has not started yet' },
172
+ // status 0 只属于客户端,不能在服务端抛出,也不会进入响应 Schema。
173
+ NETWORK_ERROR: { status: 0, message: 'Unable to reach the server' },
174
+ } as const;
175
+
176
+ export const errors = defineErrorCatalog(errorDefinitions, {
177
+ details: { CAMPAIGN_NOT_STARTED: z.object({ startsAt: z.iso.datetime() }) },
178
+ });
179
+
180
+ throw new errors.HttpError('CAMPAIGN_NOT_STARTED', undefined, { details: { startsAt } });
181
+ ```
182
+
183
+ 目录返回:
184
+
185
+ | 成员 | 用途 |
186
+ | --- | --- |
187
+ | `HttpError` | 继承 ApiError。状态码和默认文案由错误码决定,错误码、状态码和 details 都有类型 |
188
+ | `parseInput(schema, input)` | 只用于请求输入:失败抛 422 REQUEST_VALIDATION_FAILED 并带字段错误。存储数据和输出的校验失败不要用它 |
189
+ | `toHttpError(error)` | 把本目录的 HttpError 和 SDK server 函数抛出的 HTTP ApiError 统一成 HttpError;其他异常返回 undefined,由应用按 500 处理 |
190
+ | `httpErrorSchema`、`validationErrorSchema` | OpenAPI 用的失败响应 Schema;`responseSchema(msg)` 是成功响应 Schema |
191
+ | `decodeError` | 传给 createHttpClient 的 decodeError:只接受本目录的码,并校验登记过的 details;其他响应体是 RESPONSE_INVALID(目录定义了该码时使用目录文案) |
192
+ | `errorFromResponse(response)` | 服务间调用时,从非 2xx 的 Response 读出 ApiError |
193
+ | `isErrorCode`、`isHttpErrorResponse`、`codes` | 判断和列举服务端错误码 |
194
+
195
+ details 只对登记了 Schema 的错误码公开:抛出时校验失败会直接报错,解码时校验失败会丢弃。
196
+
197
+ ## Hono 接入
198
+
199
+ ```ts
200
+ import { createHonoHttp } from '@springbrand/http/hono';
201
+
202
+ export const { ok, httpError, httpNotFound, httpUnauthorized, httpValidationError, httpRateLimited, httpInternalError, httpErrorResponse } =
203
+ createHonoHttp(errors);
204
+
205
+ app.onError((error, c) => httpError(c, errors.toHttpError(error) ?? new errors.HttpError('INTERNAL_ERROR', undefined, { cause: error })));
206
+ app.openapi(route, c => found ? ok(c, item) : httpNotFound(c));
207
+ ```
208
+
209
+ 返回值保留 Hono 的 TypedResponse 类型,`@hono/zod-openapi` 会检查处理器返回的状态码和响应体是否在路由里声明过。`ok(c, msg, status?)` 支持 200、201、202。`httpError` 会执行 `c.set("apiError", error)` 供请求日志读取,请求编号读自 `c.get("requestId")`。拿不到 Context 的地方用 `httpErrorResponse(error, { requestId })`,它不会生成新的请求编号。
210
+
139
211
  ## 服务端
140
212
 
141
213
  ```ts
@@ -149,7 +221,11 @@ ok 支持 200、201、202;无内容成功直接用标准 Response。fail 只
149
221
 
150
222
  toErrorResponse 公开标准 ApiError 的安全信息,未知异常返回固定 500 INTERNAL_ERROR。业务错误和 details 在 HTTP 处理器中翻译和验证。
151
223
 
152
- createServiceRequest 为绑定来源构造服务请求,清理用户 Authorization 和 Cookie,注入 X-SpringBrand-Service-Token。verifyServiceToken 比较等长摘要。服务请求不跟随重定向,不使用用户 Bearer 的登录失效流程。
224
+ 请求体工具按实际读取的字节数限制大小,不信任 Content-Length:`readBytesLimited`、`readJsonLimited`、`limitRequestBody`(中间件中计数后重建 Request)、`readJson`。超限抛 413 REQUEST_PAYLOAD_TOO_LARGE,JSON 语法错误抛 400 REQUEST_INVALID_JSON,都是 `kind: "http"` 的 ApiError,用目录的 `toHttpError` 转成项目 HttpError。`requireIfMatchRevision(request, subject)` 读取 `If-Match: "3"` 形式的版本号。`contentDispositionHeader(filename, type?)` 生成兼容非 ASCII 文件名的下载响应头。
225
+
226
+ `errorDiagnostics(error)` 是请求日志用的异常投影:保留 cause 链、调用栈和上游状态,遮蔽凭据;带 query 或 params 的数据库异常只记录固定文案。`redactSensitiveText` 单独提供遮蔽能力。
227
+
228
+ createServiceRequest 为绑定来源构造服务请求,清理用户 Authorization 和 Cookie,注入 X-SpringBrand-Service-Token。第三个参数可以是路径,也可以是一个 Request:传 Request 时转发它的方法、请求头、请求体(流式请求体自动声明半双工)和 signal;同源 URL 原样使用,其他来源只保留路径和查询参数并绑定到 baseUrl。下游需要按调用者身份鉴权时(例如 API 代理用户请求到 Agent),显式传第五个参数 `{ forwardAuthorization: true }` 保留 Authorization;Cookie 始终删除。verifyServiceToken 比较等长摘要。服务请求使用 no-store / manual,Worker 中不设置浏览器专有的 credentials。调用方需拒绝非成功响应,尤其不得自行跟随 3xx 并转发服务凭据;不使用用户 Bearer 的登录失效流程。
153
229
 
154
230
  ## 验证与示例
155
231
 
@@ -161,6 +237,6 @@ pnpm --filter @springbrand/http build
161
237
  pnpm build-storybook
162
238
  ```
163
239
 
164
- Storybook 的 HTTP 分类包含成功、后台恢复、首次失败、字段错误、业务扩展、认证、取消、动态 Token 和上传下载。模拟服务仍通过真实 Axios 请求实现。
240
+ Storybook 的 HTTP 分类包含成功、后台恢复、首次失败、字段错误、业务扩展、认证、取消、动态 Token 和上传下载。模拟服务仍通过真实 Axios 请求实现。tests/worker.test.ts 使用 Miniflare/workerd 执行真正的 Worker,验证 JSON、Token、服务凭据及重定向保护;测试不会访问真实供应商。
165
241
 
166
242
  完整设计位于 dune-react 的 trevordocs/axios-request-sdk-architecture.md。预发布版本使用 npm 的 alpha 标签,业务项目的迁移由各项目独立安排。
@@ -0,0 +1,113 @@
1
+ import { z } from "zod";
2
+ import { ApiError } from "./errors.js";
3
+ import type { FieldError, ResponseInfo } from "./types.js";
4
+ export type ErrorDefinition = {
5
+ readonly status: number;
6
+ readonly message: string;
7
+ };
8
+ export type ErrorDefinitions = Readonly<Record<string, ErrorDefinition>>;
9
+ /** SDK 的服务端辅助函数会产生这些错误码,每个错误目录都必须定义它们。 */
10
+ export declare const requiredErrorCodes: readonly ["REQUEST_INVALID_INPUT", "REQUEST_INVALID_JSON", "REQUEST_VALIDATION_FAILED", "REQUEST_PAYLOAD_TOO_LARGE", "REQUEST_NOT_FOUND", "REQUEST_RATE_LIMITED", "AUTH_UNAUTHENTICATED", "INTERNAL_ERROR"];
11
+ export type RequiredErrorCode = (typeof requiredErrorCodes)[number];
12
+ /** 能出现在 HTTP 响应里的错误码;status 为 0 的码(如 NETWORK_ERROR)只属于客户端。 */
13
+ export type ServerErrorCode<D extends ErrorDefinitions> = {
14
+ [K in keyof D & string]: D[K]["status"] extends 0 ? never : K;
15
+ }[keyof D & string];
16
+ export type DetailSchemas<D extends ErrorDefinitions> = {
17
+ readonly [K in ServerErrorCode<D>]?: z.ZodType;
18
+ };
19
+ export type DetailsOf<S, C> = C extends keyof S ? S[C] extends z.ZodType ? z.output<S[C]> : undefined : undefined;
20
+ export type HttpErrorOptions<T> = ErrorOptions & {
21
+ errors?: FieldError[];
22
+ details?: T;
23
+ upstreamStatus?: number;
24
+ upstreamRequestId?: string;
25
+ };
26
+ export type CatalogErrorResponse<C extends string> = {
27
+ code: C;
28
+ errMsg: string;
29
+ requestId?: string;
30
+ errors?: FieldError[];
31
+ details?: unknown;
32
+ };
33
+ /** 标准成功响应 `{ success: true, msg, errMsg: "", code: "" }` 的 Schema。 */
34
+ export declare function responseSchema<T extends z.ZodType>(msgSchema: T): z.ZodObject<{
35
+ success: z.ZodLiteral<true>;
36
+ msg: T;
37
+ errMsg: z.ZodLiteral<"">;
38
+ code: z.ZodLiteral<"">;
39
+ }, z.core.$strip>;
40
+ /**
41
+ * 用项目自己的错误码目录生成带类型的 HttpError、OpenAPI Schema 和客户端解码器。
42
+ * 目录只定义一次:服务端抛错、响应 Schema 和浏览器解析都从这里派生。
43
+ */
44
+ export declare function defineErrorCatalog<const D extends ErrorDefinitions & Record<RequiredErrorCode, ErrorDefinition>, const S extends DetailSchemas<D> = Record<never, never>>(definitions: D, options?: {
45
+ details?: S;
46
+ }): {
47
+ definitions: D;
48
+ codes: readonly ServerErrorCode<D>[];
49
+ HttpError: {
50
+ new <C extends ServerErrorCode<D> = ServerErrorCode<D>>(code: C, message?: string, options?: HttpErrorOptions<DetailsOf<S, C>>): {
51
+ readonly code: C;
52
+ readonly status: D[C]["status"];
53
+ readonly upstreamStatus: number | undefined;
54
+ readonly upstreamRequestId: string | undefined;
55
+ readonly requestId?: string;
56
+ readonly errors?: FieldError[];
57
+ readonly details?: DetailsOf<S, C> | undefined;
58
+ readonly kind: import("./errors.js").ApiErrorKind;
59
+ readonly retryAfterMs?: number;
60
+ readonly context?: import("./types.js").RequestContext;
61
+ name: string;
62
+ message: string;
63
+ stack?: string;
64
+ cause?: unknown;
65
+ };
66
+ [Symbol.hasInstance](value: unknown): boolean;
67
+ captureStackTrace(targetObject: object, constructorOpt?: Function): void;
68
+ prepareStackTrace(err: Error, stackTraces: NodeJS.CallSite[]): any;
69
+ stackTraceLimit: number;
70
+ };
71
+ isErrorCode: (value: unknown) => value is ServerErrorCode<D>;
72
+ isHttpErrorResponse: (data: unknown) => data is CatalogErrorResponse<ServerErrorCode<D>>;
73
+ parseInput: <T extends z.ZodType>(schema: T, input: unknown) => z.output<T>;
74
+ toHttpError: (error: unknown) => {
75
+ readonly code: ServerErrorCode<D>;
76
+ readonly status: D[ServerErrorCode<D>]["status"];
77
+ readonly upstreamStatus: number | undefined;
78
+ readonly upstreamRequestId: string | undefined;
79
+ readonly requestId?: string;
80
+ readonly errors?: FieldError[];
81
+ readonly details?: DetailsOf<S, ServerErrorCode<D>> | undefined;
82
+ readonly kind: import("./errors.js").ApiErrorKind;
83
+ readonly retryAfterMs?: number;
84
+ readonly context?: import("./types.js").RequestContext;
85
+ name: string;
86
+ message: string;
87
+ stack?: string;
88
+ cause?: unknown;
89
+ } | undefined;
90
+ decodeError: (data: unknown, info: ResponseInfo) => ApiError;
91
+ errorFromResponse: (response: Response) => Promise<ApiError>;
92
+ httpErrorSchema: z.ZodObject<{
93
+ details?: z.ZodOptional<z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>> | undefined;
94
+ code: z.ZodEnum<{ [k_1 in ServerErrorCode<D>]: k_1; } extends infer T ? { [k in keyof T]: T[k]; } : never>;
95
+ errMsg: z.ZodString;
96
+ requestId: z.ZodOptional<z.ZodString>;
97
+ errors: z.ZodOptional<z.ZodArray<z.ZodObject<{
98
+ path: z.ZodString;
99
+ errMsg: z.ZodString;
100
+ }, z.core.$strip>>>;
101
+ }, z.core.$strip>;
102
+ validationErrorSchema: z.ZodObject<{
103
+ details?: z.ZodOptional<z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>> | undefined;
104
+ code: z.ZodEnum<{ [k_1 in ServerErrorCode<D>]: k_1; } extends infer T_1 ? { [k in keyof T_1]: T_1[k]; } : never>;
105
+ errMsg: z.ZodString;
106
+ requestId: z.ZodOptional<z.ZodString>;
107
+ errors: z.ZodArray<z.ZodObject<{
108
+ path: z.ZodString;
109
+ errMsg: z.ZodString;
110
+ }, z.core.$strip>>;
111
+ }, z.core.$strip>;
112
+ };
113
+ export type ErrorCatalogOf<D extends ErrorDefinitions & Record<RequiredErrorCode, ErrorDefinition>, S extends DetailSchemas<D> = Record<never, never>> = ReturnType<typeof defineErrorCatalog<D, S>>;
@@ -0,0 +1,135 @@
1
+ import { isRegisteredErrorCode, httpStatusOf } from "@springbrand/errors";
2
+ import { z } from "zod";
3
+ import { ApiError } from "./errors.js";
4
+ import { parseHttpError } from "./response.js";
5
+ import { sdkError } from "./sdk-error.js";
6
+ const requiredErrorCodes = [
7
+ "REQUEST_INVALID_INPUT",
8
+ "REQUEST_INVALID_JSON",
9
+ "REQUEST_VALIDATION_FAILED",
10
+ "REQUEST_PAYLOAD_TOO_LARGE",
11
+ "REQUEST_NOT_FOUND",
12
+ "REQUEST_RATE_LIMITED",
13
+ "AUTH_UNAUTHENTICATED",
14
+ "INTERNAL_ERROR"
15
+ ];
16
+ const fieldErrorsSchema = z.array(z.object({ path: z.string(), errMsg: z.string() }));
17
+ const isRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
18
+ function responseSchema(msgSchema) {
19
+ return z.object({ success: z.literal(true), msg: msgSchema, errMsg: z.literal(""), code: z.literal("") });
20
+ }
21
+ function defineErrorCatalog(definitions, options = {}) {
22
+ const detailSchemas = options.details ?? {};
23
+ for (const [code, definition] of Object.entries(definitions)) {
24
+ if (!/^[A-Z][A-Z0-9_]{0,127}$/.test(code)) throw new RangeError(`Invalid error code ${code}`);
25
+ if (definition.status !== 0 && !(Number.isInteger(definition.status) && definition.status >= 400 && definition.status <= 599)) {
26
+ throw new RangeError(`Error ${code} must use status 0 or a 4xx/5xx status`);
27
+ }
28
+ if (isRegisteredErrorCode(code) && definition.status !== (httpStatusOf(code) ?? 0)) {
29
+ throw new RangeError(`Error ${code} must use status ${httpStatusOf(code) ?? 0} registered in @springbrand/errors`);
30
+ }
31
+ }
32
+ const codes = Object.keys(definitions).filter((code) => definitions[code].status !== 0);
33
+ for (const code of Object.keys(detailSchemas)) {
34
+ if (!codes.includes(code)) throw new RangeError(`Details schema ${code} is not a server error code`);
35
+ }
36
+ function isErrorCode(value) {
37
+ return typeof value === "string" && Object.hasOwn(definitions, value) && definitions[value].status !== 0;
38
+ }
39
+ function parseDetails(code, details, strict) {
40
+ const schema = detailSchemas[code];
41
+ if (!schema || details === void 0) return void 0;
42
+ if (strict) return schema.parse(details);
43
+ const result = schema.safeParse(details);
44
+ return result.success ? result.data : void 0;
45
+ }
46
+ class HttpError extends ApiError {
47
+ upstreamStatus;
48
+ upstreamRequestId;
49
+ constructor(code, message, options2) {
50
+ if (!isErrorCode(code)) throw new RangeError(`Unknown server error code ${String(code)}`);
51
+ super({
52
+ status: definitions[code].status,
53
+ code,
54
+ message: message ?? definitions[code].message,
55
+ errors: options2?.errors,
56
+ // 只有登记了 Schema 的错误码才公开 details,且必须先通过校验。
57
+ details: parseDetails(code, options2?.details, true),
58
+ kind: "http",
59
+ cause: options2?.cause
60
+ });
61
+ this.name = "HttpError";
62
+ this.upstreamStatus = options2?.upstreamStatus;
63
+ this.upstreamRequestId = options2?.upstreamRequestId;
64
+ }
65
+ }
66
+ function parseInput(schema, input) {
67
+ const result = schema.safeParse(input);
68
+ if (!result.success) {
69
+ throw new HttpError("REQUEST_VALIDATION_FAILED", void 0, {
70
+ cause: result.error,
71
+ errors: result.error.issues.map((issue) => ({ path: issue.path.join("."), errMsg: issue.message }))
72
+ });
73
+ }
74
+ return result.data;
75
+ }
76
+ function toHttpError(error) {
77
+ if (error instanceof HttpError) return error;
78
+ if (!(error instanceof ApiError) || error.kind !== "http" || !isErrorCode(error.code)) return void 0;
79
+ return new HttpError(error.code, error.message, {
80
+ cause: error.cause ?? error,
81
+ errors: error.errors,
82
+ details: parseDetails(error.code, error.details, false)
83
+ });
84
+ }
85
+ function isHttpErrorResponse(data) {
86
+ return isRecord(data) && isErrorCode(data.code) && typeof data.errMsg === "string";
87
+ }
88
+ function decodeError(data, info) {
89
+ if (!isHttpErrorResponse(data)) {
90
+ const fallback = parseHttpError(void 0, info);
91
+ const message = definitions.RESPONSE_INVALID?.message;
92
+ const options2 = { status: info.status, kind: info.status >= 500 ? "http" : "response", requestId: fallback.requestId, retryAfterMs: fallback.retryAfterMs };
93
+ return message === void 0 ? sdkError("RESPONSE_INVALID", options2) : new ApiError({ ...options2, code: "RESPONSE_INVALID", message });
94
+ }
95
+ const parsed = parseHttpError(data, info);
96
+ const details = parseDetails(data.code, data.details, false);
97
+ return details === void 0 ? parsed : new ApiError({ ...parsed, message: parsed.message, details });
98
+ }
99
+ async function errorFromResponse(response) {
100
+ const data = await response.json().catch(() => void 0);
101
+ const headers = {};
102
+ response.headers.forEach((value, key) => {
103
+ headers[key.toLowerCase()] = value;
104
+ });
105
+ return decodeError(data, { status: response.status, headers });
106
+ }
107
+ const schemas = Object.values(detailSchemas).filter((schema) => schema !== void 0);
108
+ const detailsSchema = schemas.length === 0 ? void 0 : schemas.length === 1 ? schemas[0] : z.union(schemas);
109
+ const httpErrorSchema = z.object({
110
+ code: z.enum(codes),
111
+ errMsg: z.string(),
112
+ requestId: z.string().optional(),
113
+ errors: fieldErrorsSchema.optional(),
114
+ ...detailsSchema ? { details: detailsSchema.optional() } : {}
115
+ });
116
+ const validationErrorSchema = httpErrorSchema.extend({ errors: fieldErrorsSchema });
117
+ return {
118
+ definitions,
119
+ codes,
120
+ HttpError,
121
+ isErrorCode,
122
+ isHttpErrorResponse,
123
+ parseInput,
124
+ toHttpError,
125
+ decodeError,
126
+ errorFromResponse,
127
+ httpErrorSchema,
128
+ validationErrorSchema
129
+ };
130
+ }
131
+ export {
132
+ defineErrorCatalog,
133
+ requiredErrorCodes,
134
+ responseSchema
135
+ };
package/dist/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import axios from "axios";
2
- import { ApiError, errorDefinitions, isRequestCancelled } from "./errors.js";
2
+ import { ApiError, isRequestCancelled } from "./errors.js";
3
+ import { sdkError, keepSdkDefault, sdkMessage } from "./sdk-error.js";
3
4
  import { createErrorHandler } from "./error-handler.js";
4
5
  import { toSafeDiagnostics } from "./diagnostics.js";
5
6
  import { serviceUrl, boundUrl } from "./url.js";
@@ -13,7 +14,8 @@ function createHttpClient(config) {
13
14
  responseType: "stream",
14
15
  timeout: 0,
15
16
  withCredentials: config.credentials === "same-origin" ? void 0 : config.credentials === "include",
16
- fetchOptions: { redirect: "error" }
17
+ // Workers rejects explicit cache: default and redirect: error. Never follow API redirects.
18
+ fetchOptions: { cache: "no-store", redirect: "manual" }
17
19
  });
18
20
  const handleError = createErrorHandler(config);
19
21
  let rejectedToken;
@@ -29,19 +31,23 @@ function createHttpClient(config) {
29
31
  const method = options.method ?? "GET";
30
32
  const context = { method, feedback: options.feedback ?? "auto", feedbackOwner: options.feedbackOwner ?? config.feedbackOwner ?? "client" };
31
33
  const controller = new AbortController();
32
- const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
34
+ const signal = controller.signal;
35
+ const caller = options.signal;
36
+ const forward = () => controller.abort(caller?.reason);
37
+ if (caller?.aborted) forward();
38
+ else caller?.addEventListener("abort", forward, { once: true });
33
39
  let timer;
34
40
  let sentToken;
35
41
  let received = false;
36
42
  try {
37
- if (!["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"].includes(method) || (method === "GET" || method === "HEAD") && options.body !== void 0) throw new ApiError({ status: 0, code: "REQUEST_INVALID_INPUT", message: errorDefinitions.REQUEST_INVALID_INPUT, kind: "input" });
43
+ if (!["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"].includes(method) || (method === "GET" || method === "HEAD") && options.body !== void 0) throw sdkError("REQUEST_INVALID_INPUT", { status: 0, kind: "input" });
38
44
  const url = boundUrl(base, path);
39
45
  for (const [key, value] of Object.entries(options.params ?? {})) {
40
46
  for (const item of Array.isArray(value) ? value : [value]) if (item !== void 0 && item !== null) url.searchParams.append(key, String(item));
41
47
  }
42
48
  const timeout = options.timeoutMs ?? config.timeoutMs ?? 15e3;
43
49
  const limit = options.maxResponseBytes ?? config.maxResponseBytes ?? 10 * 1024 * 1024;
44
- if (!Number.isFinite(timeout) || timeout < 0 || !Number.isSafeInteger(limit) || limit < 0) throw new ApiError({ status: 0, code: "REQUEST_INVALID_INPUT", message: errorDefinitions.REQUEST_INVALID_INPUT, kind: "input" });
50
+ if (!Number.isFinite(timeout) || timeout < 0 || !Number.isSafeInteger(limit) || limit < 0) throw sdkError("REQUEST_INVALID_INPUT", { status: 0, kind: "input" });
45
51
  if (signal.aborted) throw signal.reason;
46
52
  const headers = new Headers(config.getHeaders?.());
47
53
  new Headers(options.headers).forEach((value, key) => headers.set(key, value));
@@ -53,15 +59,18 @@ function createHttpClient(config) {
53
59
  }
54
60
  if (options.idempotencyKey) headers.set("Idempotency-Key", options.idempotencyKey);
55
61
  if (options.body instanceof FormData) headers.delete("Content-Type");
56
- if (timeout > 0) timer = setTimeout(() => controller.abort(new ApiError({ status: 0, code: "REQUEST_TIMEOUT", message: errorDefinitions.REQUEST_TIMEOUT, kind: "timeout" })), timeout);
62
+ if (timeout > 0) timer = setTimeout(() => controller.abort(sdkError("REQUEST_TIMEOUT", { status: 0, kind: "timeout" })), timeout);
57
63
  const requestHeaders = new axios.AxiosHeaders(Object.fromEntries(headers));
58
- if (options.body instanceof FormData) requestHeaders.setContentType("multipart/form-data");
64
+ if (!requestHeaders.has("User-Agent")) requestHeaders.set("User-Agent", false);
65
+ const viaXhr = options.onUploadProgress !== void 0 && typeof XMLHttpRequest !== "undefined";
66
+ if (options.body instanceof FormData) requestHeaders.set("Content-Type", viaXhr ? false : "multipart/form-data");
59
67
  const response = await transport.request({
60
68
  url: url.href,
61
69
  method,
62
70
  headers: requestHeaders,
63
71
  data: options.body,
64
72
  signal,
73
+ ...viaXhr ? { adapter: "xhr", responseType: "arraybuffer" } : {},
65
74
  onUploadProgress: options.onUploadProgress ? (progress) => {
66
75
  try {
67
76
  options.onUploadProgress?.({ loaded: progress.loaded, total: progress.total, progress: progress.progress });
@@ -71,11 +80,20 @@ function createHttpClient(config) {
71
80
  } : void 0
72
81
  });
73
82
  received = true;
83
+ const redirected = viaXhr && response.request?.responseURL !== url.href;
84
+ const body = response.data instanceof ArrayBuffer ? new Response(response.data).body : response.data;
74
85
  const responseHeaders = {};
75
86
  const rawHeaders = response.headers instanceof axios.AxiosHeaders ? response.headers.toJSON() : response.headers;
76
87
  for (const [key, value] of Object.entries(rawHeaders)) if (typeof value === "string" || typeof value === "number" || Array.isArray(value)) responseHeaders[key.toLowerCase()] = String(value);
77
88
  const info = { status: response.status, headers: responseHeaders };
78
- const chunks = await readBody(response.data, info, limit);
89
+ if (redirected || response.status === 0 || response.status >= 300 && response.status < 400) {
90
+ try {
91
+ await body?.cancel();
92
+ } catch {
93
+ }
94
+ throw sdkError("REQUEST_REDIRECT_BLOCKED", { status: response.status, kind: "response", requestId: responseHeaders["x-request-id"] });
95
+ }
96
+ const chunks = await readBody(body, info, limit);
79
97
  if (signal.aborted) throw signal.reason;
80
98
  if (response.status < 200 || response.status >= 300) {
81
99
  const data2 = errorBody(chunks);
@@ -85,8 +103,8 @@ function createHttpClient(config) {
85
103
  if (!(decoded instanceof ApiError)) throw new Error("invalid decoder");
86
104
  throw decoded;
87
105
  } catch (error) {
88
- if (error instanceof ApiError) throw new ApiError({ ...error, status: response.status, message: error.message, requestId: error.requestId ?? responseHeaders["x-request-id"], retryAfterMs: error.retryAfterMs ?? parseRetryAfter(responseHeaders["retry-after"]) });
89
- throw new ApiError({ status: response.status, code: "RESPONSE_INVALID", message: errorDefinitions.RESPONSE_INVALID, kind: "response", requestId: responseHeaders["x-request-id"] });
106
+ if (error instanceof ApiError) throw keepSdkDefault(error, new ApiError({ ...error, status: response.status, message: error.message, requestId: error.requestId ?? responseHeaders["x-request-id"], retryAfterMs: error.retryAfterMs ?? parseRetryAfter(responseHeaders["retry-after"]) }));
107
+ throw sdkError("RESPONSE_INVALID", { status: response.status, kind: "response", requestId: responseHeaders["x-request-id"] });
90
108
  }
91
109
  }
92
110
  throw parseHttpError(data2, info);
@@ -97,21 +115,26 @@ function createHttpClient(config) {
97
115
  const reason = signal.aborted ? signal.reason : error;
98
116
  if (signal.aborted && !(reason instanceof ApiError) && !(reason instanceof Error && reason.name === "TimeoutError")) throw reason;
99
117
  if (isRequestCancelled(reason)) throw reason;
100
- const normalized = reason instanceof ApiError ? reason : new ApiError({
118
+ const runtimeUnsupported = axios.isAxiosError(reason) && (reason.code === axios.AxiosError.ERR_NOT_SUPPORT || !reason.request && reason.cause instanceof TypeError);
119
+ const timedOut = reason instanceof Error && reason.name === "TimeoutError";
120
+ const networkFailure = received || axios.isAxiosError(reason) && (reason.request instanceof Request || typeof XMLHttpRequest !== "undefined" && reason.request instanceof XMLHttpRequest);
121
+ const code = timedOut ? "REQUEST_TIMEOUT" : runtimeUnsupported ? "RUNTIME_UNSUPPORTED" : networkFailure ? "NETWORK_ERROR" : "REQUEST_INVALID_INPUT";
122
+ const normalized = reason instanceof ApiError ? reason : sdkError(code, {
101
123
  status: 0,
102
- code: reason instanceof Error && reason.name === "TimeoutError" ? "REQUEST_TIMEOUT" : received || axios.isAxiosError(reason) && reason.request instanceof Request ? "NETWORK_ERROR" : "REQUEST_INVALID_INPUT",
103
- message: reason instanceof Error && reason.name === "TimeoutError" ? errorDefinitions.REQUEST_TIMEOUT : received || axios.isAxiosError(reason) && reason.request instanceof Request ? errorDefinitions.NETWORK_ERROR : errorDefinitions.REQUEST_INVALID_INPUT,
104
- kind: reason instanceof Error && reason.name === "TimeoutError" ? "timeout" : received || axios.isAxiosError(reason) && reason.request instanceof Request ? "network" : "input"
124
+ kind: timedOut ? "timeout" : runtimeUnsupported ? "runtime" : networkFailure ? "network" : "input"
105
125
  });
106
- const failure = new ApiError({ ...normalized, message: normalized.message, context });
126
+ const failure = new ApiError({ ...normalized, message: sdkMessage(normalized, config.messages), context });
107
127
  const unauthorized = config.isUnauthorized ?? ((value) => value.status === 401 && value.code === "AUTH_UNAUTHENTICATED");
108
128
  try {
109
- if (options.handleUnauthorized !== false && sentToken && config.onUnauthorized && failure.kind === "http" && failure.status >= 400 && unauthorized(failure)) {
110
- if (config.getToken?.() !== sentToken || rejectedToken === sentToken) context.authHandled = true;
111
- else {
112
- rejectedToken = sentToken;
113
- context.authHandled = true;
114
- config.onUnauthorized(failure);
129
+ if (failure.kind === "http" && failure.status >= 400 && unauthorized(failure)) context.authHandled = true;
130
+ if (context.authHandled && options.handleUnauthorized !== false && sentToken && config.onUnauthorized && config.getToken?.() === sentToken && rejectedToken !== sentToken) {
131
+ rejectedToken = sentToken;
132
+ const handling = config.onUnauthorized(failure);
133
+ if (handling && typeof handling.then === "function") {
134
+ const handledToken = sentToken;
135
+ void Promise.resolve(handling).catch(() => void 0).finally(() => {
136
+ if (rejectedToken === handledToken) rejectedToken = void 0;
137
+ });
115
138
  }
116
139
  }
117
140
  } catch (callbackError) {
@@ -122,6 +145,7 @@ function createHttpClient(config) {
122
145
  throw failure;
123
146
  } finally {
124
147
  if (timer !== void 0) clearTimeout(timer);
148
+ caller?.removeEventListener("abort", forward);
125
149
  }
126
150
  }
127
151
  const get = (path, options) => request(path, { ...options, method: "GET" }).then((value) => value.data);
@@ -1,2 +1,2 @@
1
1
  import type { ErrorContext, HttpClientConfig } from "./types.js";
2
- export declare function createErrorHandler(config: Pick<HttpClientConfig, "notify" | "onBusinessError" | "onFailure">): (error: unknown, input?: Partial<ErrorContext>) => void;
2
+ export declare function createErrorHandler(config: Pick<HttpClientConfig, "notify" | "onBusinessError" | "onFailure" | "messages">): (error: unknown, input?: Partial<ErrorContext>) => void;