@springbrand/http 0.1.0-alpha.0 → 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.
- package/.agents/skills/dune-react-http/SKILL.md +34 -9
- package/README.md +104 -15
- package/dist/catalog.d.ts +113 -0
- package/dist/catalog.js +135 -0
- package/dist/client.js +45 -21
- package/dist/error-handler.d.ts +1 -1
- package/dist/error-handler.js +6 -3
- package/dist/errors.d.ts +8 -1
- package/dist/errors.js +15 -2
- package/dist/hono.d.ts +49 -0
- package/dist/hono.js +44 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3 -2
- package/dist/query.js +2 -2
- package/dist/response.js +11 -14
- package/dist/sdk-error.d.ts +10 -0
- package/dist/sdk-error.js +20 -0
- package/dist/server/body.d.ts +10 -0
- package/dist/server/body.js +67 -0
- package/dist/server/content-disposition.d.ts +2 -0
- package/dist/server/content-disposition.js +7 -0
- package/dist/server/error-diagnostics.d.ts +7 -0
- package/dist/server/error-diagnostics.js +25 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.js +11 -0
- package/dist/server/response.js +2 -1
- package/dist/server/service-auth.d.ts +8 -1
- package/dist/server/service-auth.js +24 -6
- package/dist/types.d.ts +9 -2
- package/dist/url.js +2 -2
- package/package.json +41 -13
package/dist/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
|
-
import { ApiError,
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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);
|
package/dist/error-handler.d.ts
CHANGED
|
@@ -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;
|
package/dist/error-handler.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { isRequestCancelled, ApiError
|
|
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 :
|
|
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 ?
|
|
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
|
@@ -5,12 +5,14 @@ export declare const errorDefinitions: {
|
|
|
5
5
|
readonly RESPONSE_INVALID: "服务返回的数据格式异常。";
|
|
6
6
|
readonly RESPONSE_TOO_LARGE: "返回内容超过读取限制。";
|
|
7
7
|
readonly REQUEST_INVALID_INPUT: "请求配置无效。";
|
|
8
|
+
readonly RUNTIME_UNSUPPORTED: "当前运行环境不支持 HTTP 请求所需能力,请升级 SDK 或检查运行时配置。";
|
|
9
|
+
readonly REQUEST_REDIRECT_BLOCKED: "接口返回了重定向,已阻止跳转,请检查 API 地址。";
|
|
8
10
|
readonly AUTH_UNAUTHENTICATED: "登录状态已失效,请重新登录。";
|
|
9
11
|
readonly REQUEST_FORBIDDEN: "没有执行此操作的权限。";
|
|
10
12
|
readonly INTERNAL_ERROR: "服务暂时无法完成请求,请稍后重试。";
|
|
11
13
|
readonly HTTP_ERROR: "请求未能完成,请检查后重试。";
|
|
12
14
|
};
|
|
13
|
-
export type ApiErrorKind = "network" | "timeout" | "http" | "response" | "input";
|
|
15
|
+
export type ApiErrorKind = "network" | "timeout" | "http" | "response" | "input" | "runtime";
|
|
14
16
|
export type ApiErrorOptions<T = unknown> = {
|
|
15
17
|
status: number;
|
|
16
18
|
code: string;
|
|
@@ -21,8 +23,11 @@ export type ApiErrorOptions<T = unknown> = {
|
|
|
21
23
|
kind?: ApiErrorKind;
|
|
22
24
|
retryAfterMs?: number;
|
|
23
25
|
context?: RequestContext;
|
|
26
|
+
cause?: unknown;
|
|
24
27
|
};
|
|
25
28
|
export declare class ApiError<T = unknown> extends Error {
|
|
29
|
+
/** 跨 SDK 实例识别 ApiError;子类(如目录的 HttpError)仍按原型链判断。 */
|
|
30
|
+
static [Symbol.hasInstance](value: unknown): boolean;
|
|
26
31
|
readonly status: number;
|
|
27
32
|
readonly code: string;
|
|
28
33
|
readonly requestId?: string;
|
|
@@ -35,3 +40,5 @@ export declare class ApiError<T = unknown> extends Error {
|
|
|
35
40
|
}
|
|
36
41
|
export declare function isRequestCancelled(error: unknown): boolean;
|
|
37
42
|
export declare const isApiError: (error: unknown) => error is ApiError;
|
|
43
|
+
/** Temporary transport or service failures; this does not authorize replaying a write. */
|
|
44
|
+
export declare function isTransientHttpError(error: unknown): error is ApiError;
|
package/dist/errors.js
CHANGED
|
@@ -4,12 +4,20 @@ const errorDefinitions = {
|
|
|
4
4
|
RESPONSE_INVALID: "服务返回的数据格式异常。",
|
|
5
5
|
RESPONSE_TOO_LARGE: "返回内容超过读取限制。",
|
|
6
6
|
REQUEST_INVALID_INPUT: "请求配置无效。",
|
|
7
|
+
RUNTIME_UNSUPPORTED: "当前运行环境不支持 HTTP 请求所需能力,请升级 SDK 或检查运行时配置。",
|
|
8
|
+
REQUEST_REDIRECT_BLOCKED: "接口返回了重定向,已阻止跳转,请检查 API 地址。",
|
|
7
9
|
AUTH_UNAUTHENTICATED: "登录状态已失效,请重新登录。",
|
|
8
10
|
REQUEST_FORBIDDEN: "没有执行此操作的权限。",
|
|
9
11
|
INTERNAL_ERROR: "服务暂时无法完成请求,请稍后重试。",
|
|
10
12
|
HTTP_ERROR: "请求未能完成,请检查后重试。"
|
|
11
13
|
};
|
|
14
|
+
const apiErrorBrand = Symbol.for("@springbrand/http.ApiError");
|
|
12
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
|
+
}
|
|
13
21
|
status;
|
|
14
22
|
code;
|
|
15
23
|
requestId;
|
|
@@ -19,7 +27,7 @@ class ApiError extends Error {
|
|
|
19
27
|
retryAfterMs;
|
|
20
28
|
context;
|
|
21
29
|
constructor(options) {
|
|
22
|
-
super(options.message);
|
|
30
|
+
super(options.message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
23
31
|
this.name = "ApiError";
|
|
24
32
|
this.status = options.status;
|
|
25
33
|
this.code = options.code;
|
|
@@ -29,6 +37,7 @@ class ApiError extends Error {
|
|
|
29
37
|
this.kind = options.kind ?? "http";
|
|
30
38
|
this.retryAfterMs = options.retryAfterMs;
|
|
31
39
|
this.context = options.context;
|
|
40
|
+
Object.defineProperty(this, apiErrorBrand, { value: true });
|
|
32
41
|
}
|
|
33
42
|
}
|
|
34
43
|
function isRequestCancelled(error) {
|
|
@@ -37,9 +46,13 @@ function isRequestCancelled(error) {
|
|
|
37
46
|
return "name" in error && error.name === "AbortError" || "code" in error && error.code === "ERR_CANCELED";
|
|
38
47
|
}
|
|
39
48
|
const isApiError = (error) => error instanceof ApiError;
|
|
49
|
+
function isTransientHttpError(error) {
|
|
50
|
+
return error instanceof ApiError && (error.kind === "network" || error.kind === "timeout" || error.kind === "http" && [429, 502, 503, 504].includes(error.status));
|
|
51
|
+
}
|
|
40
52
|
export {
|
|
41
53
|
ApiError,
|
|
42
54
|
errorDefinitions,
|
|
43
55
|
isApiError,
|
|
44
|
-
isRequestCancelled
|
|
56
|
+
isRequestCancelled,
|
|
57
|
+
isTransientHttpError
|
|
45
58
|
};
|
package/dist/hono.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
export type CatalogError<D extends ErrorDefinitions, C extends keyof D & string> = ApiError & {
|
|
8
|
+
readonly code: C;
|
|
9
|
+
readonly status: D[C]["status"];
|
|
10
|
+
};
|
|
11
|
+
export 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
|
+
export type ErrorStatus<D extends ErrorDefinitions, C extends keyof D> = D[C]["status"] & ContentfulStatusCode;
|
|
18
|
+
/** 显式写出响应类型:让 Hono 在泛型 C 未确定时推导 JSONParsed,会在声明文件里丢掉 code 字段。 */
|
|
19
|
+
export 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
|
+
};
|
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
|
-
export { ApiError, errorDefinitions, isApiError, isRequestCancelled } from "./errors.js";
|
|
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, RequestContext, RequestNotice, RequestFailure, ResponseInfo, HttpClientConfig, HttpMessages, HttpRequestOptions, HttpMethod, ResponseMode, Feedback, FeedbackOwner, QueryScalar, UploadProgress, } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { ApiError, errorDefinitions, isApiError, isRequestCancelled } from "./errors.js";
|
|
1
|
+
import { ApiError, errorDefinitions, isApiError, isRequestCancelled, isTransientHttpError } from "./errors.js";
|
|
2
2
|
export {
|
|
3
3
|
ApiError,
|
|
4
4
|
errorDefinitions,
|
|
5
5
|
isApiError,
|
|
6
|
-
isRequestCancelled
|
|
6
|
+
isRequestCancelled,
|
|
7
|
+
isTransientHttpError
|
|
7
8
|
};
|
package/dist/query.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { QueryClient, MutationCache, QueryCache, isCancelledError } from "@tanstack/react-query";
|
|
2
|
-
import { isRequestCancelled, ApiError } from "./errors.js";
|
|
2
|
+
import { isRequestCancelled, ApiError, isTransientHttpError } from "./errors.js";
|
|
3
3
|
import { toSafeDiagnostics } from "./diagnostics.js";
|
|
4
4
|
function shouldRetryRequest(failureCount, error, maxRetries = 2) {
|
|
5
5
|
if (failureCount >= maxRetries || !(error instanceof ApiError)) return false;
|
|
6
6
|
if (error.context?.method !== "GET" && error.context?.method !== "HEAD") return false;
|
|
7
|
-
return
|
|
7
|
+
return isTransientHttpError(error);
|
|
8
8
|
}
|
|
9
9
|
function requestRetryDelay(attempt, error) {
|
|
10
10
|
const requested = error instanceof ApiError ? error.retryAfterMs : void 0;
|
package/dist/response.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ApiError
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,67 @@
|
|
|
1
|
+
import { httpStatusOf } from "@springbrand/errors";
|
|
2
|
+
import { ApiError } from "../errors.js";
|
|
3
|
+
const invalidJson = (cause) => new ApiError({ status: httpStatusOf("REQUEST_INVALID_JSON"), code: "REQUEST_INVALID_JSON", message: "Expected a JSON object", kind: "http", cause });
|
|
4
|
+
const tooLarge = () => new ApiError({ status: httpStatusOf("REQUEST_PAYLOAD_TOO_LARGE"), code: "REQUEST_PAYLOAD_TOO_LARGE", message: "Request body is too large", kind: "http" });
|
|
5
|
+
async function readJson(request) {
|
|
6
|
+
try {
|
|
7
|
+
return await request.json();
|
|
8
|
+
} catch (cause) {
|
|
9
|
+
if (!(cause instanceof SyntaxError)) throw cause;
|
|
10
|
+
throw invalidJson(cause);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
async function readBytesLimited(input, maxBytes) {
|
|
14
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new RangeError("maxBytes must be a non-negative safe integer");
|
|
15
|
+
if (!input.body) return new Uint8Array();
|
|
16
|
+
const reader = input.body.getReader();
|
|
17
|
+
const chunks = [];
|
|
18
|
+
let bytes = 0;
|
|
19
|
+
try {
|
|
20
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
21
|
+
bytes += chunk.value.byteLength;
|
|
22
|
+
if (bytes > maxBytes) {
|
|
23
|
+
await reader.cancel();
|
|
24
|
+
throw tooLarge();
|
|
25
|
+
}
|
|
26
|
+
chunks.push(chunk.value);
|
|
27
|
+
}
|
|
28
|
+
} finally {
|
|
29
|
+
reader.releaseLock();
|
|
30
|
+
}
|
|
31
|
+
const body = new Uint8Array(bytes);
|
|
32
|
+
let offset = 0;
|
|
33
|
+
for (const chunk of chunks) {
|
|
34
|
+
body.set(chunk, offset);
|
|
35
|
+
offset += chunk.byteLength;
|
|
36
|
+
}
|
|
37
|
+
return body;
|
|
38
|
+
}
|
|
39
|
+
async function limitRequestBody(request, maxBytes) {
|
|
40
|
+
if (!request.body) return request;
|
|
41
|
+
const body = await readBytesLimited(request, maxBytes);
|
|
42
|
+
return new Request(request, { body, duplex: "half" });
|
|
43
|
+
}
|
|
44
|
+
async function readJsonLimited(request, maxBytes) {
|
|
45
|
+
const body = await readBytesLimited(request, maxBytes);
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(new TextDecoder().decode(body));
|
|
48
|
+
} catch (cause) {
|
|
49
|
+
if (!(cause instanceof SyntaxError)) throw cause;
|
|
50
|
+
throw invalidJson(cause);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function requireIfMatchRevision(request, subject) {
|
|
54
|
+
const match = /^"([1-9]\d*)"$/.exec(request.headers.get("if-match") ?? "");
|
|
55
|
+
const revision = match ? Number(match[1]) : Number.NaN;
|
|
56
|
+
if (!Number.isSafeInteger(revision)) {
|
|
57
|
+
throw new ApiError({ status: httpStatusOf("REQUEST_INVALID_INPUT"), code: "REQUEST_INVALID_INPUT", message: `If-Match must contain the current ${subject}, for example "3"`, kind: "http" });
|
|
58
|
+
}
|
|
59
|
+
return revision;
|
|
60
|
+
}
|
|
61
|
+
export {
|
|
62
|
+
limitRequestBody,
|
|
63
|
+
readBytesLimited,
|
|
64
|
+
readJson,
|
|
65
|
+
readJsonLimited,
|
|
66
|
+
requireIfMatchRevision
|
|
67
|
+
};
|
|
@@ -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
|
+
};
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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";
|