@springbrand/http 0.1.0-alpha.2 → 0.1.0-alpha.3

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,6 +1,6 @@
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
@@ -13,7 +13,9 @@ Read the installed declarations and README before configuring a client. Public e
13
13
  | @springbrand/http/errors | Error definitions, cancellation and transient failure classification |
14
14
  | @springbrand/http/client | Axios client and standard HTTP error parsing |
15
15
  | @springbrand/http/query | Optional TanStack React Query integration |
16
- | @springbrand/http/server | Standard Response helpers and service authentication |
16
+ | @springbrand/http/catalog | Project error catalog: typed HttpError, Zod response schemas, client decoder (zod peer) |
17
+ | @springbrand/http/hono | Hono responders with TypedResponse types for zod-openapi (hono peer) |
18
+ | @springbrand/http/server | Standard Response helpers, bounded body reading, If-Match, Content-Disposition, log diagnostics and service authentication |
17
19
 
18
20
  ## Client setup
19
21
 
@@ -33,13 +35,29 @@ Use isTransientHttpError from the core or errors entry point for cached-view rec
33
35
 
34
36
  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.
35
37
 
38
+ Override SDK-generated copy with the client messages option when the product language is not Chinese; server errMsg is unaffected.
39
+
40
+ Return a Promise from onUnauthorized when it rechecks the session, so later failures of the same token are handled again after the check settles.
41
+
42
+ Session-expiry failures (isUnauthorized) never reach notify, with or without a token; control per-request presentation with feedback.
43
+
36
44
  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.
37
45
 
46
+ ## Error catalog
47
+
48
+ 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.
49
+
50
+ 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.
51
+
52
+ ## Hono
53
+
54
+ 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.
55
+
38
56
  ## Server setup
39
57
 
40
58
  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.
41
59
 
42
- 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/Authorization or send service tokens to another origin.
60
+ 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.
43
61
 
44
62
  ## Verification
45
63
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
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
 
@@ -18,9 +18,11 @@ pnpm add @springbrand/http@alpha
18
18
  | `@springbrand/http/errors` | 基础错误码、取消识别和临时错误判断 |
19
19
  | `@springbrand/http/client` | createHttpClient、parseHttpError |
20
20
  | `@springbrand/http/query` | createHttpQueryClient、读取重试策略 |
21
- | `@springbrand/http/server` | ok、fail、toErrorResponse、服务鉴权与安全诊断 |
21
+ | `@springbrand/http/catalog` | defineErrorCatalog:项目错误码目录、HttpError、Zod Schema、客户端解码 |
22
+ | `@springbrand/http/hono` | createHonoHttp:ok、httpError 等带 TypedResponse 类型的 Hono 响应 |
23
+ | `@springbrand/http/server` | ok、fail、toErrorResponse、请求体限长读取、If-Match、Content-Disposition、服务鉴权与日志诊断 |
22
24
 
23
- 客户端依赖 Axios。Query 是独立入口,以 `@tanstack/react-query` 作为可选 peer;核心和服务端使用者无需安装 React 或 Query。服务端要求标准 Request、Response 和 Web Crypto,建议 Node.js 24 或兼容运行时。
25
+ 客户端依赖 Axios。Query、catalog、hono 是独立入口,分别以 `@tanstack/react-query`、`zod`、`hono` 作为可选 peer;只用到哪个入口才需要安装对应依赖,核心和 server 入口都不需要。服务端要求标准 Request、Response 和 Web Crypto,建议 Node.js 24 或兼容运行时。
24
26
 
25
27
  ## 请求实例
26
28
 
@@ -121,7 +123,15 @@ if (query.data !== undefined && isTransientHttpError(query.error)) {
121
123
 
122
124
  主动写入的网络失败会提示结果暂时无法确认,不自动重发。取消保持取消语义,不包装成 NETWORK_ERROR。超时为 REQUEST_TIMEOUT,格式错误为 RESPONSE_INVALID,内容超限为 RESPONSE_TOO_LARGE。请求运行时不支持所需能力时为 RUNTIME_UNSUPPORTED(kind: runtime),与非法调用参数 REQUEST_INVALID_INPUT 分开;运行时和重定向错误不重试、不清理登录。
123
125
 
124
- 默认仅 401 且 AUTH_UNAUTHENTICATED 触发失效回调。旧身份的响应不清理新身份,同一 Token 并发失效仅处理一次;网络错误、其他 401 和 403 不退出登录。默认自动处理针对实例携带的 Bearer 身份,Cookie 会话的恢复流程由消费者掌握。
126
+ 默认仅 401 且 AUTH_UNAUTHENTICATED 触发失效回调;这类登录失效交给认证流程,不论请求是否携带 Token 都不弹通用提示。单个请求的提示方式用 `feedback`(auto、inline、silent)控制。onUnauthorized 返回 Promise 时(例如复检会话),复检期间同一 Token 的重复失效只处理一次,Promise 完成后恢复处理;返回 void 时直到 Token 变化。旧身份的响应不清理新身份,同一 Token 并发失效仅处理一次;网络错误、其他 401 和 403 不退出登录。默认自动处理针对实例携带的 Bearer 身份,Cookie 会话的恢复流程由消费者掌握。
127
+
128
+ `messages` 用来替换 SDK 自身产生的提示文案(例如 NETWORK_ERROR、REQUEST_TIMEOUT,以及写操作结果无法确认时的 MUTATION_RESULT_UNCERTAIN),默认是中文。服务端返回的 errMsg 不受影响。
129
+
130
+ ```ts
131
+ createHttpClient({ baseUrl, messages: { NETWORK_ERROR: 'Unable to reach the server', MUTATION_RESULT_UNCERTAIN: 'Check the result before retrying' } });
132
+ ```
133
+
134
+ pnpm 可能按消费者的可选 peer 解析出多份 SDK 实例;`instanceof ApiError` 通过全局 Symbol 跨实例识别,目录的 HttpError 等子类仍按原型链判断。
125
135
 
126
136
  ApiError 使用对象参数,泛型 details 可用于业务详情:
127
137
 
@@ -136,6 +146,65 @@ throw new ApiError<{ revision: number }>({
136
146
 
137
147
  decodeResponse 处理特殊成功协议;decodeError 可调用公开 parseHttpError 后验证并附加业务 details。SDK 保留请求上下文、错误类和原始状态,不在公共错误中保存原始 Axios config 或凭据。
138
148
 
149
+ ## 错误码目录
150
+
151
+ 业务错误码属于项目,不属于 SDK。每个项目用 `defineErrorCatalog` 定义一次目录,服务端抛错、OpenAPI Schema 和浏览器解析都从这里派生:
152
+
153
+ ```ts
154
+ import { z } from 'zod';
155
+ import { defineErrorCatalog } from '@springbrand/http/catalog';
156
+
157
+ export const errorDefinitions = {
158
+ // SDK 服务端辅助函数会产生这些码,目录必须定义(类型检查会提示缺失)。
159
+ REQUEST_INVALID_INPUT: { status: 400, message: 'Request input is invalid' },
160
+ REQUEST_INVALID_JSON: { status: 400, message: 'Expected a JSON object' },
161
+ REQUEST_VALIDATION_FAILED: { status: 422, message: 'Validation failed' },
162
+ REQUEST_PAYLOAD_TOO_LARGE: { status: 413, message: 'Request body is too large' },
163
+ REQUEST_NOT_FOUND: { status: 404, message: 'Resource not found' },
164
+ REQUEST_RATE_LIMITED: { status: 429, message: 'Too many requests' },
165
+ AUTH_UNAUTHENTICATED: { status: 401, message: 'Authentication required' },
166
+ INTERNAL_ERROR: { status: 500, message: 'Internal server error' },
167
+ // 业务码
168
+ CAMPAIGN_NOT_STARTED: { status: 409, message: 'Campaign has not started yet' },
169
+ // status 0 只属于客户端,不能在服务端抛出,也不会进入响应 Schema。
170
+ NETWORK_ERROR: { status: 0, message: 'Unable to reach the server' },
171
+ } as const;
172
+
173
+ export const errors = defineErrorCatalog(errorDefinitions, {
174
+ details: { CAMPAIGN_NOT_STARTED: z.object({ startsAt: z.iso.datetime() }) },
175
+ });
176
+
177
+ throw new errors.HttpError('CAMPAIGN_NOT_STARTED', undefined, { details: { startsAt } });
178
+ ```
179
+
180
+ 目录返回:
181
+
182
+ | 成员 | 用途 |
183
+ | --- | --- |
184
+ | `HttpError` | 继承 ApiError。状态码和默认文案由错误码决定,错误码、状态码和 details 都有类型 |
185
+ | `parseInput(schema, input)` | 只用于请求输入:失败抛 422 REQUEST_VALIDATION_FAILED 并带字段错误。存储数据和输出的校验失败不要用它 |
186
+ | `toHttpError(error)` | 把本目录的 HttpError 和 SDK server 函数抛出的 HTTP ApiError 统一成 HttpError;其他异常返回 undefined,由应用按 500 处理 |
187
+ | `httpErrorSchema`、`validationErrorSchema` | OpenAPI 用的失败响应 Schema;`responseSchema(msg)` 是成功响应 Schema |
188
+ | `decodeError` | 传给 createHttpClient 的 decodeError:只接受本目录的码,并校验登记过的 details;其他响应体是 RESPONSE_INVALID(目录定义了该码时使用目录文案) |
189
+ | `errorFromResponse(response)` | 服务间调用时,从非 2xx 的 Response 读出 ApiError |
190
+ | `isErrorCode`、`isHttpErrorResponse`、`codes` | 判断和列举服务端错误码 |
191
+
192
+ details 只对登记了 Schema 的错误码公开:抛出时校验失败会直接报错,解码时校验失败会丢弃。
193
+
194
+ ## Hono 接入
195
+
196
+ ```ts
197
+ import { createHonoHttp } from '@springbrand/http/hono';
198
+
199
+ export const { ok, httpError, httpNotFound, httpUnauthorized, httpValidationError, httpRateLimited, httpInternalError, httpErrorResponse } =
200
+ createHonoHttp(errors);
201
+
202
+ app.onError((error, c) => httpError(c, errors.toHttpError(error) ?? new errors.HttpError('INTERNAL_ERROR', undefined, { cause: error })));
203
+ app.openapi(route, c => found ? ok(c, item) : httpNotFound(c));
204
+ ```
205
+
206
+ 返回值保留 Hono 的 TypedResponse 类型,`@hono/zod-openapi` 会检查处理器返回的状态码和响应体是否在路由里声明过。`ok(c, msg, status?)` 支持 200、201、202。`httpError` 会执行 `c.set("apiError", error)` 供请求日志读取,请求编号读自 `c.get("requestId")`。拿不到 Context 的地方用 `httpErrorResponse(error, { requestId })`,它不会生成新的请求编号。
207
+
139
208
  ## 服务端
140
209
 
141
210
  ```ts
@@ -149,7 +218,11 @@ ok 支持 200、201、202;无内容成功直接用标准 Response。fail 只
149
218
 
150
219
  toErrorResponse 公开标准 ApiError 的安全信息,未知异常返回固定 500 INTERNAL_ERROR。业务错误和 details 在 HTTP 处理器中翻译和验证。
151
220
 
152
- createServiceRequest 为绑定来源构造服务请求,清理用户 Authorization 和 Cookie,注入 X-SpringBrand-Service-Token。verifyServiceToken 比较等长摘要。服务请求使用 no-store / manual,Worker 中不设置浏览器专有的 credentials。调用方需拒绝非成功响应,尤其不得自行跟随 3xx 并转发服务凭据;不使用用户 Bearer 的登录失效流程。
221
+ 请求体工具按实际读取的字节数限制大小,不信任 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 文件名的下载响应头。
222
+
223
+ `errorDiagnostics(error)` 是请求日志用的异常投影:保留 cause 链、调用栈和上游状态,遮蔽凭据;带 query 或 params 的数据库异常只记录固定文案。`redactSensitiveText` 单独提供遮蔽能力。
224
+
225
+ 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
226
 
154
227
  ## 验证与示例
155
228
 
@@ -0,0 +1,114 @@
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
+ 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>>;
114
+ export {};
@@ -0,0 +1,131 @@
1
+ import { z } from "zod";
2
+ import { ApiError } from "./errors.js";
3
+ import { parseHttpError } from "./response.js";
4
+ import { sdkError } from "./sdk-error.js";
5
+ const requiredErrorCodes = [
6
+ "REQUEST_INVALID_INPUT",
7
+ "REQUEST_INVALID_JSON",
8
+ "REQUEST_VALIDATION_FAILED",
9
+ "REQUEST_PAYLOAD_TOO_LARGE",
10
+ "REQUEST_NOT_FOUND",
11
+ "REQUEST_RATE_LIMITED",
12
+ "AUTH_UNAUTHENTICATED",
13
+ "INTERNAL_ERROR"
14
+ ];
15
+ const fieldErrorsSchema = z.array(z.object({ path: z.string(), errMsg: z.string() }));
16
+ const isRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
17
+ function responseSchema(msgSchema) {
18
+ return z.object({ success: z.literal(true), msg: msgSchema, errMsg: z.literal(""), code: z.literal("") });
19
+ }
20
+ function defineErrorCatalog(definitions, options = {}) {
21
+ const detailSchemas = options.details ?? {};
22
+ for (const [code, definition] of Object.entries(definitions)) {
23
+ if (!/^[A-Z][A-Z0-9_]{0,127}$/.test(code)) throw new RangeError(`Invalid error code ${code}`);
24
+ if (definition.status !== 0 && !(Number.isInteger(definition.status) && definition.status >= 400 && definition.status <= 599)) {
25
+ throw new RangeError(`Error ${code} must use status 0 or a 4xx/5xx status`);
26
+ }
27
+ }
28
+ const codes = Object.keys(definitions).filter((code) => definitions[code].status !== 0);
29
+ for (const code of Object.keys(detailSchemas)) {
30
+ if (!codes.includes(code)) throw new RangeError(`Details schema ${code} is not a server error code`);
31
+ }
32
+ function isErrorCode(value) {
33
+ return typeof value === "string" && Object.hasOwn(definitions, value) && definitions[value].status !== 0;
34
+ }
35
+ function parseDetails(code, details, strict) {
36
+ const schema = detailSchemas[code];
37
+ if (!schema || details === void 0) return void 0;
38
+ if (strict) return schema.parse(details);
39
+ const result = schema.safeParse(details);
40
+ return result.success ? result.data : void 0;
41
+ }
42
+ class HttpError extends ApiError {
43
+ upstreamStatus;
44
+ upstreamRequestId;
45
+ constructor(code, message, options2) {
46
+ if (!isErrorCode(code)) throw new RangeError(`Unknown server error code ${String(code)}`);
47
+ super({
48
+ status: definitions[code].status,
49
+ code,
50
+ message: message ?? definitions[code].message,
51
+ errors: options2?.errors,
52
+ // 只有登记了 Schema 的错误码才公开 details,且必须先通过校验。
53
+ details: parseDetails(code, options2?.details, true),
54
+ kind: "http",
55
+ cause: options2?.cause
56
+ });
57
+ this.name = "HttpError";
58
+ this.upstreamStatus = options2?.upstreamStatus;
59
+ this.upstreamRequestId = options2?.upstreamRequestId;
60
+ }
61
+ }
62
+ function parseInput(schema, input) {
63
+ const result = schema.safeParse(input);
64
+ if (!result.success) {
65
+ throw new HttpError("REQUEST_VALIDATION_FAILED", void 0, {
66
+ cause: result.error,
67
+ errors: result.error.issues.map((issue) => ({ path: issue.path.join("."), errMsg: issue.message }))
68
+ });
69
+ }
70
+ return result.data;
71
+ }
72
+ function toHttpError(error) {
73
+ if (error instanceof HttpError) return error;
74
+ if (!(error instanceof ApiError) || error.kind !== "http" || !isErrorCode(error.code)) return void 0;
75
+ return new HttpError(error.code, error.message, {
76
+ cause: error.cause ?? error,
77
+ errors: error.errors,
78
+ details: parseDetails(error.code, error.details, false)
79
+ });
80
+ }
81
+ function isHttpErrorResponse(data) {
82
+ return isRecord(data) && isErrorCode(data.code) && typeof data.errMsg === "string";
83
+ }
84
+ function decodeError(data, info) {
85
+ if (!isHttpErrorResponse(data)) {
86
+ const fallback = parseHttpError(void 0, info);
87
+ const message = definitions.RESPONSE_INVALID?.message;
88
+ const options2 = { status: info.status, kind: info.status >= 500 ? "http" : "response", requestId: fallback.requestId, retryAfterMs: fallback.retryAfterMs };
89
+ return message === void 0 ? sdkError("RESPONSE_INVALID", options2) : new ApiError({ ...options2, code: "RESPONSE_INVALID", message });
90
+ }
91
+ const parsed = parseHttpError(data, info);
92
+ const details = parseDetails(data.code, data.details, false);
93
+ return details === void 0 ? parsed : new ApiError({ ...parsed, message: parsed.message, details });
94
+ }
95
+ async function errorFromResponse(response) {
96
+ const data = await response.json().catch(() => void 0);
97
+ const headers = {};
98
+ response.headers.forEach((value, key) => {
99
+ headers[key.toLowerCase()] = value;
100
+ });
101
+ return decodeError(data, { status: response.status, headers });
102
+ }
103
+ const schemas = Object.values(detailSchemas).filter((schema) => schema !== void 0);
104
+ const detailsSchema = schemas.length === 0 ? void 0 : schemas.length === 1 ? schemas[0] : z.union(schemas);
105
+ const httpErrorSchema = z.object({
106
+ code: z.enum(codes),
107
+ errMsg: z.string(),
108
+ requestId: z.string().optional(),
109
+ errors: fieldErrorsSchema.optional(),
110
+ ...detailsSchema ? { details: detailsSchema.optional() } : {}
111
+ });
112
+ const validationErrorSchema = httpErrorSchema.extend({ errors: fieldErrorsSchema });
113
+ return {
114
+ definitions,
115
+ codes,
116
+ HttpError,
117
+ isErrorCode,
118
+ isHttpErrorResponse,
119
+ parseInput,
120
+ toHttpError,
121
+ decodeError,
122
+ errorFromResponse,
123
+ httpErrorSchema,
124
+ validationErrorSchema
125
+ };
126
+ }
127
+ export {
128
+ defineErrorCatalog,
129
+ requiredErrorCodes,
130
+ responseSchema
131
+ };
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";
@@ -35,14 +36,14 @@ function createHttpClient(config) {
35
36
  let sentToken;
36
37
  let received = false;
37
38
  try {
38
- 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" });
39
+ 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" });
39
40
  const url = boundUrl(base, path);
40
41
  for (const [key, value] of Object.entries(options.params ?? {})) {
41
42
  for (const item of Array.isArray(value) ? value : [value]) if (item !== void 0 && item !== null) url.searchParams.append(key, String(item));
42
43
  }
43
44
  const timeout = options.timeoutMs ?? config.timeoutMs ?? 15e3;
44
45
  const limit = options.maxResponseBytes ?? config.maxResponseBytes ?? 10 * 1024 * 1024;
45
- 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" });
46
+ if (!Number.isFinite(timeout) || timeout < 0 || !Number.isSafeInteger(limit) || limit < 0) throw sdkError("REQUEST_INVALID_INPUT", { status: 0, kind: "input" });
46
47
  if (signal.aborted) throw signal.reason;
47
48
  const headers = new Headers(config.getHeaders?.());
48
49
  new Headers(options.headers).forEach((value, key) => headers.set(key, value));
@@ -54,7 +55,7 @@ function createHttpClient(config) {
54
55
  }
55
56
  if (options.idempotencyKey) headers.set("Idempotency-Key", options.idempotencyKey);
56
57
  if (options.body instanceof FormData) headers.delete("Content-Type");
57
- if (timeout > 0) timer = setTimeout(() => controller.abort(new ApiError({ status: 0, code: "REQUEST_TIMEOUT", message: errorDefinitions.REQUEST_TIMEOUT, kind: "timeout" })), timeout);
58
+ if (timeout > 0) timer = setTimeout(() => controller.abort(sdkError("REQUEST_TIMEOUT", { status: 0, kind: "timeout" })), timeout);
58
59
  const requestHeaders = new axios.AxiosHeaders(Object.fromEntries(headers));
59
60
  if (options.body instanceof FormData) requestHeaders.setContentType("multipart/form-data");
60
61
  const response = await transport.request({
@@ -81,7 +82,7 @@ function createHttpClient(config) {
81
82
  await response.data?.cancel();
82
83
  } catch {
83
84
  }
84
- throw new ApiError({ status: response.status, code: "REQUEST_REDIRECT_BLOCKED", message: errorDefinitions.REQUEST_REDIRECT_BLOCKED, kind: "response", requestId: responseHeaders["x-request-id"] });
85
+ throw sdkError("REQUEST_REDIRECT_BLOCKED", { status: response.status, kind: "response", requestId: responseHeaders["x-request-id"] });
85
86
  }
86
87
  const chunks = await readBody(response.data, info, limit);
87
88
  if (signal.aborted) throw signal.reason;
@@ -93,8 +94,8 @@ function createHttpClient(config) {
93
94
  if (!(decoded instanceof ApiError)) throw new Error("invalid decoder");
94
95
  throw decoded;
95
96
  } catch (error) {
96
- 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"]) });
97
- throw new ApiError({ status: response.status, code: "RESPONSE_INVALID", message: errorDefinitions.RESPONSE_INVALID, kind: "response", requestId: responseHeaders["x-request-id"] });
97
+ 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"]) }));
98
+ throw sdkError("RESPONSE_INVALID", { status: response.status, kind: "response", requestId: responseHeaders["x-request-id"] });
98
99
  }
99
100
  }
100
101
  throw parseHttpError(data2, info);
@@ -109,21 +110,22 @@ function createHttpClient(config) {
109
110
  const timedOut = reason instanceof Error && reason.name === "TimeoutError";
110
111
  const networkFailure = received || axios.isAxiosError(reason) && reason.request instanceof Request;
111
112
  const code = timedOut ? "REQUEST_TIMEOUT" : runtimeUnsupported ? "RUNTIME_UNSUPPORTED" : networkFailure ? "NETWORK_ERROR" : "REQUEST_INVALID_INPUT";
112
- const normalized = reason instanceof ApiError ? reason : new ApiError({
113
+ const normalized = reason instanceof ApiError ? reason : sdkError(code, {
113
114
  status: 0,
114
- code,
115
- message: errorDefinitions[code],
116
115
  kind: timedOut ? "timeout" : runtimeUnsupported ? "runtime" : networkFailure ? "network" : "input"
117
116
  });
118
- const failure = new ApiError({ ...normalized, message: normalized.message, context });
117
+ const failure = new ApiError({ ...normalized, message: sdkMessage(normalized, config.messages), context });
119
118
  const unauthorized = config.isUnauthorized ?? ((value) => value.status === 401 && value.code === "AUTH_UNAUTHENTICATED");
120
119
  try {
121
- if (options.handleUnauthorized !== false && sentToken && config.onUnauthorized && failure.kind === "http" && failure.status >= 400 && unauthorized(failure)) {
122
- if (config.getToken?.() !== sentToken || rejectedToken === sentToken) context.authHandled = true;
123
- else {
124
- rejectedToken = sentToken;
125
- context.authHandled = true;
126
- config.onUnauthorized(failure);
120
+ if (failure.kind === "http" && failure.status >= 400 && unauthorized(failure)) context.authHandled = true;
121
+ if (context.authHandled && options.handleUnauthorized !== false && sentToken && config.onUnauthorized && config.getToken?.() === sentToken && rejectedToken !== sentToken) {
122
+ rejectedToken = sentToken;
123
+ const handling = config.onUnauthorized(failure);
124
+ if (handling && typeof handling.then === "function") {
125
+ const handledToken = sentToken;
126
+ void Promise.resolve(handling).catch(() => void 0).finally(() => {
127
+ if (rejectedToken === handledToken) rejectedToken = void 0;
128
+ });
127
129
  }
128
130
  }
129
131
  } catch (callbackError) {
@@ -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;
@@ -1,6 +1,8 @@
1
- import { isRequestCancelled, ApiError, errorDefinitions } from "./errors.js";
1
+ import { isRequestCancelled, ApiError } from "./errors.js";
2
+ import { sdkError, sdkMessage } from "./sdk-error.js";
2
3
  import { toSafeDiagnostics } from "./diagnostics.js";
3
4
  const handled = /* @__PURE__ */ new WeakSet();
5
+ const mutationResultUncertain = "暂时无法确认操作结果,请保留输入并检查结果后再重试。";
4
6
  function createErrorHandler(config) {
5
7
  function callback(run) {
6
8
  try {
@@ -14,7 +16,8 @@ function createErrorHandler(config) {
14
16
  }
15
17
  return function handleError(error, input = {}) {
16
18
  if (isRequestCancelled(error) || error && typeof error === "object" && handled.has(error)) return;
17
- const apiError = error instanceof ApiError ? error : new ApiError({ status: 0, code: "INTERNAL_ERROR", message: errorDefinitions.INTERNAL_ERROR, kind: "response" });
19
+ const apiError = error instanceof ApiError ? error : sdkError("INTERNAL_ERROR", { status: 0, kind: "response" });
20
+ const message = sdkMessage(apiError, config.messages);
18
21
  const method = apiError.context?.method;
19
22
  const context = {
20
23
  intent: input.intent ?? (method === "GET" || method === "HEAD" ? "read" : "mutation"),
@@ -34,7 +37,7 @@ function createErrorHandler(config) {
34
37
  if (error && typeof error === "object") handled.add(error);
35
38
  const uncertain = context.intent === "mutation" && (apiError.kind === "network" || apiError.kind === "timeout");
36
39
  callback(() => config.notify?.({
37
- message: uncertain ? "暂时无法确认操作结果,请保留输入并检查结果后再重试。" : apiError.message,
40
+ message: uncertain ? config.messages?.MUTATION_RESULT_UNCERTAIN ?? mutationResultUncertain : message,
38
41
  requestId: apiError.requestId,
39
42
  id: apiError.kind === "network" || apiError.kind === "timeout" ? "http-network" : void 0
40
43
  }));
package/dist/errors.d.ts CHANGED
@@ -23,8 +23,11 @@ export type ApiErrorOptions<T = unknown> = {
23
23
  kind?: ApiErrorKind;
24
24
  retryAfterMs?: number;
25
25
  context?: RequestContext;
26
+ cause?: unknown;
26
27
  };
27
28
  export declare class ApiError<T = unknown> extends Error {
29
+ /** 跨 SDK 实例识别 ApiError;子类(如目录的 HttpError)仍按原型链判断。 */
30
+ static [Symbol.hasInstance](value: unknown): boolean;
28
31
  readonly status: number;
29
32
  readonly code: string;
30
33
  readonly requestId?: string;
package/dist/errors.js CHANGED
@@ -11,7 +11,13 @@ const errorDefinitions = {
11
11
  INTERNAL_ERROR: "服务暂时无法完成请求,请稍后重试。",
12
12
  HTTP_ERROR: "请求未能完成,请检查后重试。"
13
13
  };
14
+ const apiErrorBrand = Symbol.for("@springbrand/http.ApiError");
14
15
  class ApiError extends Error {
16
+ /** 跨 SDK 实例识别 ApiError;子类(如目录的 HttpError)仍按原型链判断。 */
17
+ static [Symbol.hasInstance](value) {
18
+ if (this !== ApiError) return Function.prototype[Symbol.hasInstance].call(this, value);
19
+ return Boolean(value && typeof value === "object" && value[apiErrorBrand] === true);
20
+ }
15
21
  status;
16
22
  code;
17
23
  requestId;
@@ -21,7 +27,7 @@ class ApiError extends Error {
21
27
  retryAfterMs;
22
28
  context;
23
29
  constructor(options) {
24
- super(options.message);
30
+ super(options.message, options.cause === void 0 ? void 0 : { cause: options.cause });
25
31
  this.name = "ApiError";
26
32
  this.status = options.status;
27
33
  this.code = options.code;
@@ -31,6 +37,7 @@ class ApiError extends Error {
31
37
  this.kind = options.kind ?? "http";
32
38
  this.retryAfterMs = options.retryAfterMs;
33
39
  this.context = options.context;
40
+ Object.defineProperty(this, apiErrorBrand, { value: true });
34
41
  }
35
42
  }
36
43
  function isRequestCancelled(error) {
package/dist/hono.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { Context, TypedResponse } from "hono";
2
+ import type { JSONParsed, JSONValue } from "hono/utils/types";
3
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
4
+ import type { ErrorDefinition, ErrorDefinitions, RequiredErrorCode } from "./catalog.js";
5
+ import type { ApiError } from "./errors.js";
6
+ import type { FieldError } from "./types.js";
7
+ type CatalogError<D extends ErrorDefinitions, C extends keyof D & string> = ApiError & {
8
+ readonly code: C;
9
+ readonly status: D[C]["status"];
10
+ };
11
+ type SuccessResponse<T, S extends 200 | 201 | 202> = Response & TypedResponse<JSONParsed<{
12
+ success: true;
13
+ msg: T;
14
+ errMsg: "";
15
+ code: "";
16
+ }>, S, "json">;
17
+ type ErrorStatus<D extends ErrorDefinitions, C extends keyof D> = D[C]["status"] & ContentfulStatusCode;
18
+ /** 显式写出响应类型:让 Hono 在泛型 C 未确定时推导 JSONParsed,会在声明文件里丢掉 code 字段。 */
19
+ type ErrorResponse<D extends ErrorDefinitions, C extends keyof D & string> = Response & TypedResponse<{
20
+ errMsg: string;
21
+ code: C;
22
+ requestId?: string;
23
+ errors?: FieldError[];
24
+ details?: JSONValue;
25
+ }, ErrorStatus<D, C>, "json">;
26
+ /**
27
+ * 把 defineErrorCatalog 的结果接到 Hono。返回值保留 Hono 的 TypedResponse 类型,
28
+ * zod-openapi 可以检查每个路由声明的状态码和响应体。
29
+ * httpError 只接受本目录 HttpError 构造的错误;只属于客户端的码(status 0)无法构造。
30
+ * 失败响应会执行 `c.set("apiError", error)` 供请求日志读取;请求编号读自 `c.get("requestId")`。
31
+ */
32
+ export declare function createHonoHttp<const D extends ErrorDefinitions & Record<RequiredErrorCode, ErrorDefinition>>(catalog: {
33
+ readonly definitions: D;
34
+ readonly HttpError: abstract new (...args: never[]) => ApiError;
35
+ }): {
36
+ ok: {
37
+ <T>(c: Context, msg: T): SuccessResponse<T, 200>;
38
+ <T, S extends 200 | 201 | 202>(c: Context, msg: T, status: S): SuccessResponse<T, S>;
39
+ };
40
+ httpError: <C extends keyof D & string>(c: Context, error: CatalogError<D, C>) => ErrorResponse<D, C>;
41
+ httpErrorResponse: <C extends keyof D & string>(error: CatalogError<D, C>, options?: {
42
+ requestId?: string;
43
+ }) => Response;
44
+ httpUnauthorized: (c: Context, message?: string) => ErrorResponse<D, "AUTH_UNAUTHENTICATED">;
45
+ httpNotFound: (c: Context, message?: string) => ErrorResponse<D, "REQUEST_NOT_FOUND">;
46
+ httpRateLimited: (c: Context, message?: string) => ErrorResponse<D, "REQUEST_RATE_LIMITED">;
47
+ httpInternalError: (c: Context, message?: string) => ErrorResponse<D, "INTERNAL_ERROR">;
48
+ httpValidationError(c: Context, errors: FieldError[]): ErrorResponse<D, "REQUEST_VALIDATION_FAILED">;
49
+ };
50
+ export {};
package/dist/hono.js ADDED
@@ -0,0 +1,44 @@
1
+ function createHonoHttp(catalog) {
2
+ const HttpError = catalog.HttpError;
3
+ function requestId(c) {
4
+ const value = c.get("requestId");
5
+ return typeof value === "string" ? value : void 0;
6
+ }
7
+ function errorBody(error, id) {
8
+ return {
9
+ errMsg: error.message,
10
+ code: error.code,
11
+ ...id ? { requestId: id } : {},
12
+ ...error.errors ? { errors: error.errors } : {},
13
+ ...error.details !== void 0 ? { details: error.details } : {}
14
+ };
15
+ }
16
+ function ok(c, msg, status = 200) {
17
+ return c.json({ success: true, msg, errMsg: "", code: "" }, status);
18
+ }
19
+ function httpError(c, error) {
20
+ c.set("apiError", error);
21
+ return c.json(errorBody(error, requestId(c)), error.status);
22
+ }
23
+ function httpErrorResponse(error, options = {}) {
24
+ return Response.json(errorBody(error, options.requestId), { status: error.status });
25
+ }
26
+ function shortcut(code) {
27
+ return (c, message) => httpError(c, new HttpError(code, message));
28
+ }
29
+ return {
30
+ ok,
31
+ httpError,
32
+ httpErrorResponse,
33
+ httpUnauthorized: shortcut("AUTH_UNAUTHENTICATED"),
34
+ httpNotFound: shortcut("REQUEST_NOT_FOUND"),
35
+ httpRateLimited: shortcut("REQUEST_RATE_LIMITED"),
36
+ httpInternalError: shortcut("INTERNAL_ERROR"),
37
+ httpValidationError(c, errors) {
38
+ return httpError(c, new HttpError("REQUEST_VALIDATION_FAILED", void 0, { errors }));
39
+ }
40
+ };
41
+ }
42
+ export {
43
+ createHonoHttp
44
+ };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { ApiError, errorDefinitions, isApiError, isRequestCancelled, isTransientHttpError } from "./errors.js";
2
2
  export type { ApiErrorOptions, ApiErrorKind } from "./errors.js";
3
- export type { ApiSuccessResponse, HttpErrorResponse, FieldError, HttpResult, ErrorContext, RequestNotice, RequestFailure, HttpClientConfig, HttpRequestOptions, HttpMethod, ResponseMode } from "./types.js";
3
+ export type { ApiSuccessResponse, HttpErrorResponse, FieldError, HttpResult, ErrorContext, RequestNotice, RequestFailure, HttpClientConfig, HttpMessages, HttpRequestOptions, HttpMethod, ResponseMode } from "./types.js";
package/dist/response.js CHANGED
@@ -1,4 +1,5 @@
1
- import { ApiError, errorDefinitions } from "./errors.js";
1
+ import { ApiError } from "./errors.js";
2
+ import { sdkError } from "./sdk-error.js";
2
3
  const record = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
3
4
  function parseRetryAfter(value) {
4
5
  if (!value) return void 0;
@@ -10,14 +11,10 @@ function parseHttpError(data, info) {
10
11
  const body = record(data) ? data : {};
11
12
  const valid = typeof body.code === "string" && /^[A-Z][A-Z0-9_]{0,127}$/.test(body.code) && typeof body.errMsg === "string";
12
13
  const errors = Array.isArray(body.errors) ? body.errors.filter((item) => record(item) && typeof item.path === "string" && typeof item.errMsg === "string").slice(0, 100).map((item) => ({ path: item.path.slice(0, 256), errMsg: item.errMsg.slice(0, 2048) })) : void 0;
13
- return new ApiError({
14
- status: info.status,
15
- code: valid ? body.code : info.status >= 500 ? "INTERNAL_ERROR" : "HTTP_ERROR",
16
- message: valid ? body.errMsg.slice(0, 4096) : info.status >= 500 ? errorDefinitions.INTERNAL_ERROR : errorDefinitions.HTTP_ERROR,
17
- requestId: typeof body.requestId === "string" ? body.requestId.slice(0, 256) : info.headers["x-request-id"],
18
- errors: valid ? errors : void 0,
19
- retryAfterMs: parseRetryAfter(info.headers["retry-after"])
20
- });
14
+ const requestId = typeof body.requestId === "string" ? body.requestId.slice(0, 256) : info.headers["x-request-id"];
15
+ const retryAfterMs = parseRetryAfter(info.headers["retry-after"]);
16
+ if (!valid) return sdkError(info.status >= 500 ? "INTERNAL_ERROR" : "HTTP_ERROR", { status: info.status, requestId, retryAfterMs });
17
+ return new ApiError({ status: info.status, code: body.code, message: body.errMsg.slice(0, 4096), requestId, errors, retryAfterMs });
21
18
  }
22
19
  async function readBody(body, info, limit) {
23
20
  if (!body) return [];
@@ -25,12 +22,12 @@ async function readBody(body, info, limit) {
25
22
  const chunks = [];
26
23
  let bytes = 0;
27
24
  try {
28
- if (Number(info.headers["content-length"]) > limit) throw new ApiError({ status: info.status, code: "RESPONSE_TOO_LARGE", message: errorDefinitions.RESPONSE_TOO_LARGE, kind: "response", requestId: info.headers["x-request-id"] });
25
+ if (Number(info.headers["content-length"]) > limit) throw sdkError("RESPONSE_TOO_LARGE", { status: info.status, kind: "response", requestId: info.headers["x-request-id"] });
29
26
  let chunk = await reader.read();
30
27
  while (!chunk.done) {
31
28
  const value = chunk.value;
32
29
  bytes += value.byteLength;
33
- if (bytes > limit) throw new ApiError({ status: info.status, code: "RESPONSE_TOO_LARGE", message: errorDefinitions.RESPONSE_TOO_LARGE, kind: "response", requestId: info.headers["x-request-id"] });
30
+ if (bytes > limit) throw sdkError("RESPONSE_TOO_LARGE", { status: info.status, kind: "response", requestId: info.headers["x-request-id"] });
34
31
  chunks.push(value);
35
32
  chunk = await reader.read();
36
33
  }
@@ -56,7 +53,7 @@ function decodeBody(chunks, mode, info, config) {
56
53
  try {
57
54
  value = text ? JSON.parse(text) : void 0;
58
55
  } catch {
59
- throw new ApiError({ status: info.status, code: "RESPONSE_INVALID", message: errorDefinitions.RESPONSE_INVALID, kind: "response", requestId: info.headers["x-request-id"] });
56
+ throw sdkError("RESPONSE_INVALID", { status: info.status, kind: "response", requestId: info.headers["x-request-id"] });
60
57
  }
61
58
  }
62
59
  }
@@ -65,12 +62,12 @@ function decodeBody(chunks, mode, info, config) {
65
62
  return config.decodeResponse(value, info);
66
63
  } catch (error) {
67
64
  if (error instanceof ApiError) throw error;
68
- throw new ApiError({ status: info.status, code: "RESPONSE_INVALID", message: errorDefinitions.RESPONSE_INVALID, kind: "response", requestId: info.headers["x-request-id"] });
65
+ throw sdkError("RESPONSE_INVALID", { status: info.status, kind: "response", requestId: info.headers["x-request-id"] });
69
66
  }
70
67
  }
71
68
  if (mode !== "envelope") return value;
72
69
  if (record(value) && value.success === true && Object.hasOwn(value, "msg")) return value.msg;
73
- throw new ApiError({ status: info.status, code: "RESPONSE_INVALID", message: errorDefinitions.RESPONSE_INVALID, kind: "response", requestId: info.headers["x-request-id"] });
70
+ throw sdkError("RESPONSE_INVALID", { status: info.status, kind: "response", requestId: info.headers["x-request-id"] });
74
71
  }
75
72
  function errorBody(chunks) {
76
73
  try {
@@ -0,0 +1,10 @@
1
+ import { ApiError, errorDefinitions, type ApiErrorOptions } from "./errors.js";
2
+ import type { HttpMessages } from "./types.js";
3
+ type SdkErrorCode = keyof typeof errorDefinitions;
4
+ /** SDK 生成的错误使用默认文案,并登记为可被消费者文案替换。 */
5
+ export declare function sdkError(code: SdkErrorCode, options: Omit<ApiErrorOptions, "code" | "message">): ApiError;
6
+ /** 服务端返回的 errMsg 保持原样,只替换 SDK 自身产生的默认文案。 */
7
+ export declare function sdkMessage(error: ApiError, messages: HttpMessages | undefined): string;
8
+ /** 重新包装 SDK 错误时保留“默认文案”标记。 */
9
+ export declare function keepSdkDefault(from: ApiError, to: ApiError): ApiError;
10
+ export {};
@@ -0,0 +1,20 @@
1
+ import { ApiError, errorDefinitions } from "./errors.js";
2
+ const defaultMessage = /* @__PURE__ */ new WeakSet();
3
+ function sdkError(code, options) {
4
+ const error = new ApiError({ ...options, code, message: errorDefinitions[code] });
5
+ defaultMessage.add(error);
6
+ return error;
7
+ }
8
+ function sdkMessage(error, messages) {
9
+ if (!defaultMessage.has(error)) return error.message;
10
+ return messages?.[error.code] ?? error.message;
11
+ }
12
+ function keepSdkDefault(from, to) {
13
+ if (defaultMessage.has(from)) defaultMessage.add(to);
14
+ return to;
15
+ }
16
+ export {
17
+ keepSdkDefault,
18
+ sdkError,
19
+ sdkMessage
20
+ };
@@ -0,0 +1,10 @@
1
+ /** 解析未校验的请求 JSON;语法错误是 400,不能变成 500。 */
2
+ export declare function readJson(request: Request): Promise<unknown>;
3
+ /** 按实际读取的字节数限制请求或响应体,不信任 Content-Length。 */
4
+ export declare function readBytesLimited(input: Request | Response, maxBytes: number): Promise<Uint8Array>;
5
+ /** 计数后重建请求,供中间件在交给后续处理器前限制请求体。 */
6
+ export declare function limitRequestBody(request: Request, maxBytes: number): Promise<Request>;
7
+ /** 限长读取请求 JSON。 */
8
+ export declare function readJsonLimited(request: Request, maxBytes: number): Promise<unknown>;
9
+ /** 从 If-Match 读取一个带引号的正整数版本,例如 "3"。 */
10
+ export declare function requireIfMatchRevision(request: Request, subject: string): number;
@@ -0,0 +1,66 @@
1
+ import { ApiError } from "../errors.js";
2
+ const invalidJson = (cause) => new ApiError({ status: 400, code: "REQUEST_INVALID_JSON", message: "Expected a JSON object", kind: "http", cause });
3
+ const tooLarge = () => new ApiError({ status: 413, code: "REQUEST_PAYLOAD_TOO_LARGE", message: "Request body is too large", kind: "http" });
4
+ async function readJson(request) {
5
+ try {
6
+ return await request.json();
7
+ } catch (cause) {
8
+ if (!(cause instanceof SyntaxError)) throw cause;
9
+ throw invalidJson(cause);
10
+ }
11
+ }
12
+ async function readBytesLimited(input, maxBytes) {
13
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new RangeError("maxBytes must be a non-negative safe integer");
14
+ if (!input.body) return new Uint8Array();
15
+ const reader = input.body.getReader();
16
+ const chunks = [];
17
+ let bytes = 0;
18
+ try {
19
+ for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
20
+ bytes += chunk.value.byteLength;
21
+ if (bytes > maxBytes) {
22
+ await reader.cancel();
23
+ throw tooLarge();
24
+ }
25
+ chunks.push(chunk.value);
26
+ }
27
+ } finally {
28
+ reader.releaseLock();
29
+ }
30
+ const body = new Uint8Array(bytes);
31
+ let offset = 0;
32
+ for (const chunk of chunks) {
33
+ body.set(chunk, offset);
34
+ offset += chunk.byteLength;
35
+ }
36
+ return body;
37
+ }
38
+ async function limitRequestBody(request, maxBytes) {
39
+ if (!request.body) return request;
40
+ const body = await readBytesLimited(request, maxBytes);
41
+ return new Request(request, { body, duplex: "half" });
42
+ }
43
+ async function readJsonLimited(request, maxBytes) {
44
+ const body = await readBytesLimited(request, maxBytes);
45
+ try {
46
+ return JSON.parse(new TextDecoder().decode(body));
47
+ } catch (cause) {
48
+ if (!(cause instanceof SyntaxError)) throw cause;
49
+ throw invalidJson(cause);
50
+ }
51
+ }
52
+ function requireIfMatchRevision(request, subject) {
53
+ const match = /^"([1-9]\d*)"$/.exec(request.headers.get("if-match") ?? "");
54
+ const revision = match ? Number(match[1]) : Number.NaN;
55
+ if (!Number.isSafeInteger(revision)) {
56
+ throw new ApiError({ status: 400, code: "REQUEST_INVALID_INPUT", message: `If-Match must contain the current ${subject}, for example "3"`, kind: "http" });
57
+ }
58
+ return revision;
59
+ }
60
+ export {
61
+ limitRequestBody,
62
+ readBytesLimited,
63
+ readJson,
64
+ readJsonLimited,
65
+ requireIfMatchRevision
66
+ };
@@ -0,0 +1,2 @@
1
+ /** 生成兼容非 ASCII 文件名的 Content-Disposition 响应头。 */
2
+ export declare function contentDispositionHeader(filename: string, type?: "attachment" | "inline"): string;
@@ -0,0 +1,7 @@
1
+ import { create } from "content-disposition";
2
+ function contentDispositionHeader(filename, type = "attachment") {
3
+ return create(filename, { type });
4
+ }
5
+ export {
6
+ contentDispositionHeader
7
+ };
@@ -0,0 +1,7 @@
1
+ /** 遮蔽凭据,但不截断调用方提供的诊断文本。 */
2
+ export declare function redactSensitiveText(text: string): string;
3
+ /**
4
+ * 服务端日志用的异常投影:保留 cause 链和调用栈,不序列化 SQL、参数或请求体。
5
+ * 带 query/params 的数据库异常只记录固定文案。
6
+ */
7
+ export declare function errorDiagnostics(error: unknown, depth?: number): Record<string, unknown> | undefined;
@@ -0,0 +1,25 @@
1
+ function redactSensitiveText(text) {
2
+ return text.replace(/Bearer\s+[^\s"']+/gi, "Bearer [Redacted]").replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+:[^\s/@]+@/gi, "$1[Redacted]@").replace(/(["']?(?:password|secret|token|api[_-]?key|authorization)["']?\s*[=:]\s*)(?:"[^"\n]*"|'[^'\n]*'|[^\s&,;]+)/gi, "$1[Redacted]").replace(/\beyJ[\w-]+\.[\w-]+\.[\w-]+\b/g, "[Redacted JWT]");
3
+ }
4
+ const redact = (text) => redactSensitiveText(text).slice(0, 1e3);
5
+ function errorDiagnostics(error, depth = 0) {
6
+ if (!(error instanceof Error) || depth >= 5) return void 0;
7
+ const fields = error;
8
+ const databaseQuery = "query" in error || "params" in error;
9
+ const code = typeof fields.code === "string" && /^[A-Za-z0-9_]{1,80}$/.test(fields.code) ? fields.code : void 0;
10
+ return {
11
+ name: error.name,
12
+ message: databaseQuery ? "Database query failed" : error instanceof SyntaxError ? "Invalid syntax" : redact(error.message),
13
+ ...code ? { code } : {},
14
+ ...typeof fields.requestId === "string" ? { providerRequestId: redact(fields.requestId) } : {},
15
+ ...typeof fields.statusCode === "number" ? { providerStatus: fields.statusCode } : {},
16
+ stack: error.stack?.split("\n").filter((line) => /^\s+at /.test(line)).slice(0, 12).map(redact).join("\n"),
17
+ ...typeof fields.upstreamStatus === "number" ? { upstreamStatus: fields.upstreamStatus } : {},
18
+ ...typeof fields.upstreamRequestId === "string" ? { upstreamRequestId: redact(fields.upstreamRequestId) } : {},
19
+ cause: errorDiagnostics(error.cause, depth + 1)
20
+ };
21
+ }
22
+ export {
23
+ errorDiagnostics,
24
+ redactSensitiveText
25
+ };
@@ -1,4 +1,7 @@
1
1
  export { ok, fail, toErrorResponse } from "./response.js";
2
2
  export type { ServerResponseOptions, HttpFailureInput } from "./response.js";
3
3
  export { createServiceRequest, verifyServiceToken, SERVICE_TOKEN_HEADER } from "./service-auth.js";
4
+ export { readJson, readJsonLimited, readBytesLimited, limitRequestBody, requireIfMatchRevision } from "./body.js";
5
+ export { contentDispositionHeader } from "./content-disposition.js";
6
+ export { errorDiagnostics, redactSensitiveText } from "./error-diagnostics.js";
4
7
  export { toSafeDiagnostics } from "../diagnostics.js";
@@ -1,11 +1,22 @@
1
1
  import { fail, ok, toErrorResponse } from "./response.js";
2
2
  import { SERVICE_TOKEN_HEADER, createServiceRequest, verifyServiceToken } from "./service-auth.js";
3
+ import { limitRequestBody, readBytesLimited, readJson, readJsonLimited, requireIfMatchRevision } from "./body.js";
4
+ import { contentDispositionHeader } from "./content-disposition.js";
5
+ import { errorDiagnostics, redactSensitiveText } from "./error-diagnostics.js";
3
6
  import { toSafeDiagnostics } from "../diagnostics.js";
4
7
  export {
5
8
  SERVICE_TOKEN_HEADER,
9
+ contentDispositionHeader,
6
10
  createServiceRequest,
11
+ errorDiagnostics,
7
12
  fail,
13
+ limitRequestBody,
8
14
  ok,
15
+ readBytesLimited,
16
+ readJson,
17
+ readJsonLimited,
18
+ redactSensitiveText,
19
+ requireIfMatchRevision,
9
20
  toErrorResponse,
10
21
  toSafeDiagnostics,
11
22
  verifyServiceToken
@@ -1,3 +1,10 @@
1
1
  export declare const SERVICE_TOKEN_HEADER = "X-SpringBrand-Service-Token";
2
- export declare function createServiceRequest(baseUrl: string, token: string, path: string | URL, init?: RequestInit): Request;
2
+ /**
3
+ * 构造绑定来源的服务请求。传入 Request 时会转发它的方法、请求头、请求体和 signal;
4
+ * 同源 URL 原样使用,其他来源只保留路径和查询参数并绑定到 baseUrl。
5
+ * 默认删除用户的 Authorization 和 Cookie;只有下游需要按调用者身份鉴权时,才显式传 forwardAuthorization。
6
+ */
7
+ export declare function createServiceRequest(baseUrl: string | URL, token: string, input: string | URL | Request, init?: RequestInit, behavior?: {
8
+ forwardAuthorization?: boolean;
9
+ }): Request;
3
10
  export declare function verifyServiceToken(provided: string | null | undefined, expected: string): Promise<boolean>;
@@ -1,13 +1,28 @@
1
- import { boundUrl, serviceUrl } from "../url.js";
1
+ import { serviceUrl, boundUrl } from "../url.js";
2
2
  const SERVICE_TOKEN_HEADER = "X-SpringBrand-Service-Token";
3
- function createServiceRequest(baseUrl, token, path, init = {}) {
3
+ function createServiceRequest(baseUrl, token, input, init = {}, behavior = {}) {
4
4
  if (!token.trim()) throw new TypeError("Service token is required");
5
- const url = boundUrl(serviceUrl(baseUrl), path);
6
- const headers = new Headers(init.headers);
7
- headers.delete("Authorization");
5
+ const base = serviceUrl(String(baseUrl));
6
+ const source = input instanceof Request ? input : void 0;
7
+ let path = source ? new URL(source.url) : input;
8
+ if (path instanceof URL && source && path.origin !== base.origin) path = `${path.pathname}${path.search}`;
9
+ const url = boundUrl(base, path);
10
+ const headers = new Headers(source?.headers);
11
+ new Headers(init.headers).forEach((value, key) => headers.set(key, value));
12
+ if (!behavior.forwardAuthorization) headers.delete("Authorization");
8
13
  headers.delete("Cookie");
9
14
  headers.set(SERVICE_TOKEN_HEADER, token);
10
- const options = { ...init, headers, cache: "no-store", redirect: "manual" };
15
+ const body = init.body !== void 0 ? init.body : source?.body ?? null;
16
+ const options = {
17
+ ...init,
18
+ method: init.method ?? source?.method ?? "GET",
19
+ headers,
20
+ body,
21
+ signal: init.signal ?? source?.signal ?? null,
22
+ cache: "no-store",
23
+ redirect: "manual"
24
+ };
25
+ if (body instanceof ReadableStream) options.duplex = "half";
11
26
  if ("credentials" in Request.prototype) options.credentials = "omit";
12
27
  else delete options.credentials;
13
28
  return new Request(url, options);
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ApiError } from "./errors.js";
1
+ import type { ApiError, errorDefinitions } from "./errors.js";
2
2
  export type HttpMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS";
3
3
  export type ResponseMode = "envelope" | "json" | "text" | "blob";
4
4
  export type Feedback = "auto" | "inline" | "silent";
@@ -77,6 +77,8 @@ export type HttpRequestOptions = {
77
77
  idempotencyKey?: string;
78
78
  onUploadProgress?: (progress: UploadProgress) => void;
79
79
  };
80
+ /** 替换 SDK 自身产生的提示文案;服务端返回的 errMsg 不受影响。 */
81
+ export type HttpMessages = Partial<Record<keyof typeof errorDefinitions | "MUTATION_RESULT_UNCERTAIN", string>>;
80
82
  export type HttpClientConfig = {
81
83
  baseUrl: string;
82
84
  timeoutMs?: number;
@@ -88,11 +90,16 @@ export type HttpClientConfig = {
88
90
  decodeResponse?: (data: unknown, info: ResponseInfo) => unknown;
89
91
  decodeError?: (data: unknown, info: ResponseInfo) => ApiError;
90
92
  isUnauthorized?: (error: ApiError) => boolean;
91
- onUnauthorized?: (error: ApiError) => void;
93
+ /**
94
+ * 当前 Bearer 被确认失效时调用。同一 token 的重复失效只处理一次:
95
+ * 返回 Promise 时,Promise 完成后恢复处理(例如复检会话后 token 仍有效);返回 void 时直到 token 变化。
96
+ */
97
+ onUnauthorized?: (error: ApiError) => void | Promise<unknown>;
92
98
  notify?: (notice: RequestNotice) => void;
93
99
  onBusinessError?: (error: ApiError, context: ErrorContext) => boolean;
94
100
  feedbackOwner?: FeedbackOwner;
95
101
  onFailure?: (failure: RequestFailure) => void;
102
+ messages?: HttpMessages;
96
103
  /** 注入标准 Fetch,便于测试或适配宿主;请求仍由 Axios 执行。 */
97
104
  fetch?: typeof globalThis.fetch;
98
105
  };
package/dist/url.js CHANGED
@@ -1,6 +1,6 @@
1
- import { ApiError, errorDefinitions } from "./errors.js";
1
+ import { sdkError } from "./sdk-error.js";
2
2
  function invalidUrl() {
3
- throw new ApiError({ status: 0, code: "REQUEST_INVALID_INPUT", message: errorDefinitions.REQUEST_INVALID_INPUT, kind: "input" });
3
+ throw sdkError("REQUEST_INVALID_INPUT", { status: 0, kind: "input" });
4
4
  }
5
5
  function serviceUrl(baseUrl) {
6
6
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/http",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -38,6 +38,16 @@
38
38
  "import": "./dist/query.js",
39
39
  "default": "./dist/query.js"
40
40
  },
41
+ "./catalog": {
42
+ "types": "./dist/catalog.d.ts",
43
+ "import": "./dist/catalog.js",
44
+ "default": "./dist/catalog.js"
45
+ },
46
+ "./hono": {
47
+ "types": "./dist/hono.d.ts",
48
+ "import": "./dist/hono.js",
49
+ "default": "./dist/hono.js"
50
+ },
41
51
  "./server": {
42
52
  "types": "./dist/server/index.d.ts",
43
53
  "import": "./dist/server/index.js",
@@ -45,31 +55,42 @@
45
55
  },
46
56
  "./package.json": "./package.json"
47
57
  },
48
- "scripts": {
49
- "build": "vite build && tsc -p tsconfig.build.json",
50
- "watch": "vite build --watch",
51
- "typecheck": "tsc --noEmit",
52
- "lint": "eslint src tests --ext ts --max-warnings 0",
53
- "test": "vitest run"
54
- },
55
58
  "dependencies": {
56
- "axios": "1.20.0"
59
+ "axios": "1.20.0",
60
+ "content-disposition": "2.0.1"
57
61
  },
58
62
  "peerDependencies": {
59
- "@tanstack/react-query": "^5.104.1"
63
+ "@tanstack/react-query": "^5.104.1",
64
+ "hono": "^4.12.0",
65
+ "zod": "^4.0.0"
60
66
  },
61
67
  "peerDependenciesMeta": {
62
68
  "@tanstack/react-query": {
63
69
  "optional": true
70
+ },
71
+ "hono": {
72
+ "optional": true
73
+ },
74
+ "zod": {
75
+ "optional": true
64
76
  }
65
77
  },
66
78
  "devDependencies": {
67
79
  "@tanstack/react-query": "5.104.1",
68
80
  "@types/node": "^20.8.3",
69
81
  "esbuild": "0.27.7",
82
+ "hono": "4.12.33",
70
83
  "miniflare": "5.20260910.0-alpha",
71
84
  "typescript": "^5.0.2",
72
85
  "vite": "^5.4.19",
73
- "vitest": "3.2.6"
86
+ "vitest": "3.2.6",
87
+ "zod": "4.6.5"
88
+ },
89
+ "scripts": {
90
+ "build": "vite build && tsc -p tsconfig.build.json",
91
+ "watch": "vite build --watch",
92
+ "typecheck": "tsc --noEmit",
93
+ "lint": "eslint src tests --ext ts --max-warnings 0",
94
+ "test": "vitest run"
74
95
  }
75
- }
96
+ }