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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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
@@ -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;
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) {
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
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/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,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,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,4 @@
1
+ import { httpStatusOf } from "@springbrand/errors";
1
2
  import { ApiError, errorDefinitions } from "../errors.js";
2
3
  function responseHeaders(options) {
3
4
  const requestId = options.requestId ?? crypto.randomUUID();
@@ -22,7 +23,7 @@ function toErrorResponse(error, options = {}) {
22
23
  if (error instanceof ApiError && error.kind === "http" && Number.isInteger(error.status) && error.status >= 400 && error.status <= 599 && /^[A-Z][A-Z0-9_]{0,127}$/.test(error.code) && !["NETWORK_ERROR", "REQUEST_TIMEOUT", "RESPONSE_TOO_LARGE"].includes(error.code)) {
23
24
  return fail({ status: error.status, code: error.code, message: error.message, errors: error.errors, details: error.details, requestId: options.requestId ?? error.requestId, headers: options.headers });
24
25
  }
25
- return fail({ status: 500, code: "INTERNAL_ERROR", message: errorDefinitions.INTERNAL_ERROR, ...options });
26
+ return fail({ status: httpStatusOf("INTERNAL_ERROR"), code: "INTERNAL_ERROR", message: errorDefinitions.INTERNAL_ERROR, ...options });
26
27
  }
27
28
  export {
28
29
  fail,
@@ -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,31 @@
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
- return new Request(url, { ...init, headers, credentials: "omit", redirect: "error" });
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";
26
+ if ("credentials" in Request.prototype) options.credentials = "omit";
27
+ else delete options.credentials;
28
+ return new Request(url, options);
11
29
  }
12
30
  async function verifyServiceToken(provided, expected) {
13
31
  if (!provided || !expected) return false;
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 {