@springbrand/http 0.0.0-stage → 0.1.0-alpha.0

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.
@@ -0,0 +1,42 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # Dune React HTTP
7
+
8
+ Read the installed declarations and README before configuring a client. Public entry points own different runtime needs; import only the capability needed.
9
+
10
+ | Entry point | Responsibility |
11
+ | --- | --- |
12
+ | @springbrand/http | Runtime-neutral contracts and one ApiError class |
13
+ | @springbrand/http/errors | Error definitions and cancellation recognition |
14
+ | @springbrand/http/client | Axios client and standard HTTP error parsing |
15
+ | @springbrand/http/query | Optional TanStack React Query integration |
16
+ | @springbrand/http/server | Standard Response helpers and service authentication |
17
+
18
+ ## Client setup
19
+
20
+ Create one client per trusted API origin. Inject getToken; do not copy Token storage or Bearer construction into requests. Relative paths include the configured base path. Foreign origins and URL credentials are rejected. Defaults are Cookie omit, 15-second deadline and 10-MiB response limit.
21
+
22
+ Use get/post/put/patch/delete for payloads, request for data plus status and headers. Explicitly choose json, text or blob for endpoints without the standard success envelope. FormData owns its boundary. Pass Query's signal through; do not wrap cancellation as network failure.
23
+
24
+ ## Error handling
25
+
26
+ Errors stay rejected even when presentation is silent or a business callback returns true. Never return fake successful data, match error messages, or serialize an Axios error/config to logs. Business detail validation belongs to the consumer's decoder; default parsing does not expose arbitrary details.
27
+
28
+ Choose one feedback owner: client for direct calls, query for Query/Mutation. Inline pages must render errors. Query integration retries safe reads only, with at most two extra attempts by default; mutations do not retry. Preserve cache after background failure. User refresh can explicitly request user feedback.
29
+
30
+ Use the guarded onMutationSuccess(meta) hook for application-owned cache invalidation. The SDK passes metadata without owning business query keys; callback failures preserve successful mutation results.
31
+
32
+ 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.
33
+
34
+ ## Server setup
35
+
36
+ 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.
37
+
38
+ Use service request/verification helpers for service credentials, independently of user authentication. Never forward user Cookie/Authorization or send service tokens to another origin.
39
+
40
+ ## Verification
41
+
42
+ Exercise transient read recovery, write uncertainty, field errors, cancellation, Token changes and callback failures. Verify server imports without React Query and packaged public entry points. Use HTTP Storybook examples as behavior references, not copied implementations. Do not add app-local parallel clients or edit installed node_modules.
package/README.md CHANGED
@@ -1,3 +1,153 @@
1
- # Temporary Holding Version
1
+ # SpringBrand HTTP SDK
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 一个包提供共享 HTTP 合同、Axios 客户端、Query 接入和框架无关的服务端辅助函数。错误规则由 SDK 维护,消费者提供 Token、通知和业务回调。
4
+
5
+ ## 安装
6
+
7
+ ```sh
8
+ pnpm add @springbrand/http@alpha
9
+ ```
10
+
11
+ 使用 Query 入口时,另外安装兼容的 `@tanstack/react-query`。
12
+
13
+ ## 入口
14
+
15
+ | 入口 | 能力 |
16
+ | --- | --- |
17
+ | `@springbrand/http` | 共享类型与 ApiError,无浏览器初始化 |
18
+ | `@springbrand/http/errors` | 基础错误码和取消识别 |
19
+ | `@springbrand/http/client` | createHttpClient、parseHttpError |
20
+ | `@springbrand/http/query` | createHttpQueryClient、读取重试策略 |
21
+ | `@springbrand/http/server` | ok、fail、toErrorResponse、服务鉴权与安全诊断 |
22
+
23
+ 客户端依赖 Axios。Query 是独立入口,以 `@tanstack/react-query` 作为可选 peer;核心和服务端使用者无需安装 React 或 Query。服务端要求标准 Request、Response 和 Web Crypto,建议 Node.js 24 或兼容运行时。
24
+
25
+ ## 请求实例
26
+
27
+ ```ts
28
+ import { createHttpClient } from '@springbrand/http/client';
29
+
30
+ let token: string | null = null;
31
+ const api = createHttpClient({
32
+ baseUrl: 'https://api.example.com/api',
33
+ getToken: () => token,
34
+ notify: notice => showNotice(notice),
35
+ onUnauthorized: () => { token = null; },
36
+ });
37
+
38
+ const items = await api.get<Item[]>('/items');
39
+ await api.post('/items', { name: 'Example' });
40
+ ```
41
+
42
+ Token 每次发送时读取。实例只向绑定来源发送请求;非法地址、带用户名密码的 URL 和其他来源的绝对 URL 会在发送前失败。`baseUrl` 的路径前缀会与相对接口路径拼接。
43
+
44
+ 客户端使用 Axios Fetch Adapter,默认 `credentials: 'omit'`。Cookie 登录使用显式 `credentials: 'include'` 的独立实例。`auth: false` 关闭 SDK 的 Bearer 注入,Cookie 策略不随之改变。有 getToken 时,SDK 管理 Authorization,额外请求头不能覆盖它。
45
+
46
+ ### 响应与下载
47
+
48
+ 默认解包 `{ success: true, msg, errMsg: '', code: '' }`。失败保留真实 HTTP 状态、稳定错误码、字段错误和请求编号。未验证的 details 不直接透传;需要业务详情时通过 decodeError 校验。
49
+
50
+ get、post、put、patch、delete 返回业务数据。通用 request 返回 `{ data, status, headers }`,响应头名称统一为小写。
51
+
52
+ ```ts
53
+ const file = await api.request<Blob>('/download', { responseMode: 'blob' });
54
+ const filenameHeader = file.headers['content-disposition'];
55
+ ```
56
+
57
+ 普通 JSON、文本和 Blob 分别使用 json、text、blob 模式。GET 和 HEAD 不接受请求体。FormData 不手工设置 multipart boundary。
58
+
59
+ 默认超时 15 秒,覆盖 timeoutMs,0 表示关闭超时。默认最大响应 10 MiB,覆盖 maxResponseBytes;0 仅允许空内容。客户端在读取数据流时检查实际字节数,超限取消读取。第一版不暴露无限流、SSE 或原生 Response 合同。
60
+
61
+ ## Query 接入
62
+
63
+ ```ts
64
+ import { createHttpQueryClient } from '@springbrand/http/query';
65
+
66
+ const api = createHttpClient({
67
+ baseUrl: 'https://api.example.com',
68
+ feedbackOwner: 'query',
69
+ notify: notice => showNotice(notice),
70
+ });
71
+ const queryClient = createHttpQueryClient({ handleError: api.handleError });
72
+
73
+ // 传入 QueryClientProvider,业务 queryKey 由功能模块管理。
74
+ useQuery({
75
+ queryKey: ['items'],
76
+ queryFn: ({ signal }) => api.get<Item[]>('/items', { signal }),
77
+ meta: { feedback: 'inline' },
78
+ });
79
+ ```
80
+
81
+ 默认最多两次额外重试,只针对已确认 GET 或 HEAD 的网络错误、超时和 429、502、503、504。Mutation 默认不重试;POST 即使误放进 queryFn 也不会获得默认重试权限。业务主动覆盖 Query 的 retry 选项后,由消费者承担该操作的重试合同。
82
+
83
+ 恢复可见或网络恢复时,Query 刷新过期数据。已有缓存的后台错误静默,首次加载在页面内展示;SDK 不吞掉失败。用户主动刷新需要在失败后用 `api.handleError(error, { intent: 'read', trigger: 'user' })` 明确反馈。
84
+
85
+ Query 项目中的直接命令式调用使用 `{ feedbackOwner: 'client' }`;经 Mutation 管理的操作继续由 Query 负责最终反馈,避免重复提示。
86
+
87
+ 业务缓存失效通过 `createHttpQueryClient` 的 `onMutationSuccess(meta)` 扩展接入。它接收 Mutation 元数据,由消费者解释业务缓存键;回调失败不会改变已经成功的 Mutation 结果。
88
+
89
+ ## 错误和业务扩展
90
+
91
+ ```ts
92
+ import { ApiError, isRequestCancelled } from '@springbrand/http';
93
+
94
+ const api = createHttpClient({
95
+ baseUrl: 'https://api.example.com',
96
+ notify: notice => showNotice(notice),
97
+ onBusinessError(error, context) {
98
+ if (context.intent === 'mutation' && error.code === 'ACTION_REQUIRED') {
99
+ openActionDialog();
100
+ return true;
101
+ }
102
+ return false;
103
+ },
104
+ });
105
+ ```
106
+
107
+ 返回 true 只接管提示,原请求仍然拒绝。inline、silent 和后台场景不打开全局业务窗口。业务、通知或诊断回调自身异常不替换原始请求结果。
108
+
109
+ 主动写入的网络失败会提示结果暂时无法确认,不自动重发。取消保持取消语义,不包装成 NETWORK_ERROR。超时为 REQUEST_TIMEOUT,格式错误为 RESPONSE_INVALID,内容超限为 RESPONSE_TOO_LARGE。
110
+
111
+ 默认仅 401 且 AUTH_UNAUTHENTICATED 触发失效回调。旧身份的响应不清理新身份,同一 Token 并发失效仅处理一次;网络错误、其他 401 和 403 不退出登录。默认自动处理针对实例携带的 Bearer 身份,Cookie 会话的恢复流程由消费者掌握。
112
+
113
+ ApiError 使用对象参数,泛型 details 可用于业务详情:
114
+
115
+ ```ts
116
+ throw new ApiError<{ revision: number }>({
117
+ status: 409,
118
+ code: 'VERSION_CONFLICT',
119
+ message: 'Please refresh before saving',
120
+ details: { revision: 2 },
121
+ });
122
+ ```
123
+
124
+ decodeResponse 处理特殊成功协议;decodeError 可调用公开 parseHttpError 后验证并附加业务 details。SDK 保留请求上下文、错误类和原始状态,不在公共错误中保存原始 Axios config 或凭据。
125
+
126
+ ## 服务端
127
+
128
+ ```ts
129
+ import { ok, fail, toErrorResponse } from '@springbrand/http/server';
130
+
131
+ return ok({ items: [] }, { requestId });
132
+ return fail({ status: 403, code: 'REQUEST_FORBIDDEN', message: 'Access denied', requestId });
133
+ ```
134
+
135
+ ok 支持 200、201、202;无内容成功直接用标准 Response。fail 只接受真实 4xx、5xx,拒绝客户端网络码。请求编号同时进入错误响应体和 X-Request-ID,缺失时自动生成。
136
+
137
+ toErrorResponse 公开标准 ApiError 的安全信息,未知异常返回固定 500 INTERNAL_ERROR。业务错误和 details 在 HTTP 处理器中翻译和验证。
138
+
139
+ createServiceRequest 为绑定来源构造服务请求,清理用户 Authorization 和 Cookie,注入 X-SpringBrand-Service-Token。verifyServiceToken 比较等长摘要。服务请求不跟随重定向,不使用用户 Bearer 的登录失效流程。
140
+
141
+ ## 验证与示例
142
+
143
+ ```sh
144
+ pnpm --filter @springbrand/http typecheck
145
+ pnpm --filter @springbrand/http lint
146
+ pnpm --filter @springbrand/http test
147
+ pnpm --filter @springbrand/http build
148
+ pnpm build-storybook
149
+ ```
150
+
151
+ Storybook 的 HTTP 分类包含成功、后台恢复、首次失败、字段错误、业务扩展、认证、取消、动态 Token 和上传下载。模拟服务仍通过真实 Axios 请求实现。
152
+
153
+ 完整设计位于 dune-react 的 trevordocs/axios-request-sdk-architecture.md。预发布版本使用 npm 的 alpha 标签,业务项目的迁移由各项目独立安排。
@@ -0,0 +1,14 @@
1
+ import type { HttpClientConfig, HttpRequestOptions, HttpResult } from "./types.js";
2
+ export type { HttpClientConfig, HttpRequestOptions, HttpResult } from "./types.js";
3
+ export { ApiError } from "./errors.js";
4
+ export { parseHttpError } from "./response.js";
5
+ export declare function createHttpClient(config: HttpClientConfig): {
6
+ request: <T>(path: string, options?: HttpRequestOptions) => Promise<HttpResult<T>>;
7
+ get: <T>(path: string, options?: HttpRequestOptions) => Promise<T>;
8
+ post: <T>(path: string, body?: unknown, options?: HttpRequestOptions) => Promise<T>;
9
+ put: <T>(path: string, body?: unknown, options?: HttpRequestOptions) => Promise<T>;
10
+ patch: <T>(path: string, body?: unknown, options?: HttpRequestOptions) => Promise<T>;
11
+ delete: <T>(path: string, options?: HttpRequestOptions) => Promise<T>;
12
+ handleError: (error: unknown, input?: Partial<import("./types.js").ErrorContext>) => void;
13
+ };
14
+ export type HttpClient = ReturnType<typeof createHttpClient>;
package/dist/client.js ADDED
@@ -0,0 +1,138 @@
1
+ import axios from "axios";
2
+ import { ApiError, errorDefinitions, isRequestCancelled } from "./errors.js";
3
+ import { createErrorHandler } from "./error-handler.js";
4
+ import { toSafeDiagnostics } from "./diagnostics.js";
5
+ import { serviceUrl, boundUrl } from "./url.js";
6
+ import { readBody, errorBody, parseRetryAfter, parseHttpError, decodeBody } from "./response.js";
7
+ function createHttpClient(config) {
8
+ const base = serviceUrl(config.baseUrl);
9
+ const transport = axios.create({
10
+ adapter: "fetch",
11
+ env: { fetch: config.fetch ?? globalThis.fetch },
12
+ validateStatus: () => true,
13
+ responseType: "stream",
14
+ timeout: 0,
15
+ withCredentials: config.credentials === "same-origin" ? void 0 : config.credentials === "include",
16
+ fetchOptions: { redirect: "error" }
17
+ });
18
+ const handleError = createErrorHandler(config);
19
+ let rejectedToken;
20
+ function report(error, started, method, stage = "request") {
21
+ if (isRequestCancelled(error)) return;
22
+ try {
23
+ config.onFailure?.(toSafeDiagnostics(error, { method, origin: base.origin, elapsedMs: Date.now() - started, stage }));
24
+ } catch {
25
+ }
26
+ }
27
+ async function request(path, options = {}) {
28
+ const started = Date.now();
29
+ const method = options.method ?? "GET";
30
+ const context = { method, feedback: options.feedback ?? "auto", feedbackOwner: options.feedbackOwner ?? config.feedbackOwner ?? "client" };
31
+ const controller = new AbortController();
32
+ const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
33
+ let timer;
34
+ let sentToken;
35
+ let received = false;
36
+ try {
37
+ if (!["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"].includes(method) || (method === "GET" || method === "HEAD") && options.body !== void 0) throw new ApiError({ status: 0, code: "REQUEST_INVALID_INPUT", message: errorDefinitions.REQUEST_INVALID_INPUT, kind: "input" });
38
+ const url = boundUrl(base, path);
39
+ for (const [key, value] of Object.entries(options.params ?? {})) {
40
+ for (const item of Array.isArray(value) ? value : [value]) if (item !== void 0 && item !== null) url.searchParams.append(key, String(item));
41
+ }
42
+ const timeout = options.timeoutMs ?? config.timeoutMs ?? 15e3;
43
+ const limit = options.maxResponseBytes ?? config.maxResponseBytes ?? 10 * 1024 * 1024;
44
+ if (!Number.isFinite(timeout) || timeout < 0 || !Number.isSafeInteger(limit) || limit < 0) throw new ApiError({ status: 0, code: "REQUEST_INVALID_INPUT", message: errorDefinitions.REQUEST_INVALID_INPUT, kind: "input" });
45
+ if (signal.aborted) throw signal.reason;
46
+ const headers = new Headers(config.getHeaders?.());
47
+ new Headers(options.headers).forEach((value, key) => headers.set(key, value));
48
+ if (config.getToken) {
49
+ headers.delete("Authorization");
50
+ sentToken = options.auth === false ? null : config.getToken();
51
+ if (sentToken !== rejectedToken) rejectedToken = void 0;
52
+ if (sentToken) headers.set("Authorization", `Bearer ${sentToken}`);
53
+ }
54
+ if (options.idempotencyKey) headers.set("Idempotency-Key", options.idempotencyKey);
55
+ if (options.body instanceof FormData) headers.delete("Content-Type");
56
+ if (timeout > 0) timer = setTimeout(() => controller.abort(new ApiError({ status: 0, code: "REQUEST_TIMEOUT", message: errorDefinitions.REQUEST_TIMEOUT, kind: "timeout" })), timeout);
57
+ const requestHeaders = new axios.AxiosHeaders(Object.fromEntries(headers));
58
+ if (options.body instanceof FormData) requestHeaders.setContentType("multipart/form-data");
59
+ const response = await transport.request({
60
+ url: url.href,
61
+ method,
62
+ headers: requestHeaders,
63
+ data: options.body,
64
+ signal,
65
+ onUploadProgress: options.onUploadProgress ? (progress) => {
66
+ try {
67
+ options.onUploadProgress?.({ loaded: progress.loaded, total: progress.total, progress: progress.progress });
68
+ } catch (error) {
69
+ report(error, started, method, "callback");
70
+ }
71
+ } : void 0
72
+ });
73
+ received = true;
74
+ const responseHeaders = {};
75
+ const rawHeaders = response.headers instanceof axios.AxiosHeaders ? response.headers.toJSON() : response.headers;
76
+ for (const [key, value] of Object.entries(rawHeaders)) if (typeof value === "string" || typeof value === "number" || Array.isArray(value)) responseHeaders[key.toLowerCase()] = String(value);
77
+ const info = { status: response.status, headers: responseHeaders };
78
+ const chunks = await readBody(response.data, info, limit);
79
+ if (signal.aborted) throw signal.reason;
80
+ if (response.status < 200 || response.status >= 300) {
81
+ const data2 = errorBody(chunks);
82
+ if (config.decodeError) {
83
+ try {
84
+ const decoded = config.decodeError(data2, info);
85
+ if (!(decoded instanceof ApiError)) throw new Error("invalid decoder");
86
+ throw decoded;
87
+ } catch (error) {
88
+ if (error instanceof ApiError) throw new ApiError({ ...error, status: response.status, message: error.message, requestId: error.requestId ?? responseHeaders["x-request-id"], retryAfterMs: error.retryAfterMs ?? parseRetryAfter(responseHeaders["retry-after"]) });
89
+ throw new ApiError({ status: response.status, code: "RESPONSE_INVALID", message: errorDefinitions.RESPONSE_INVALID, kind: "response", requestId: responseHeaders["x-request-id"] });
90
+ }
91
+ }
92
+ throw parseHttpError(data2, info);
93
+ }
94
+ const data = decodeBody(chunks, options.responseMode ?? config.responseMode ?? "envelope", info, config);
95
+ return { data, status: response.status, headers: responseHeaders };
96
+ } catch (error) {
97
+ const reason = signal.aborted ? signal.reason : error;
98
+ if (signal.aborted && !(reason instanceof ApiError) && !(reason instanceof Error && reason.name === "TimeoutError")) throw reason;
99
+ if (isRequestCancelled(reason)) throw reason;
100
+ const normalized = reason instanceof ApiError ? reason : new ApiError({
101
+ status: 0,
102
+ code: reason instanceof Error && reason.name === "TimeoutError" ? "REQUEST_TIMEOUT" : received || axios.isAxiosError(reason) && reason.request instanceof Request ? "NETWORK_ERROR" : "REQUEST_INVALID_INPUT",
103
+ message: reason instanceof Error && reason.name === "TimeoutError" ? errorDefinitions.REQUEST_TIMEOUT : received || axios.isAxiosError(reason) && reason.request instanceof Request ? errorDefinitions.NETWORK_ERROR : errorDefinitions.REQUEST_INVALID_INPUT,
104
+ kind: reason instanceof Error && reason.name === "TimeoutError" ? "timeout" : received || axios.isAxiosError(reason) && reason.request instanceof Request ? "network" : "input"
105
+ });
106
+ const failure = new ApiError({ ...normalized, message: normalized.message, context });
107
+ const unauthorized = config.isUnauthorized ?? ((value) => value.status === 401 && value.code === "AUTH_UNAUTHENTICATED");
108
+ try {
109
+ if (options.handleUnauthorized !== false && sentToken && config.onUnauthorized && failure.kind === "http" && failure.status >= 400 && unauthorized(failure)) {
110
+ if (config.getToken?.() !== sentToken || rejectedToken === sentToken) context.authHandled = true;
111
+ else {
112
+ rejectedToken = sentToken;
113
+ context.authHandled = true;
114
+ config.onUnauthorized(failure);
115
+ }
116
+ }
117
+ } catch (callbackError) {
118
+ report(callbackError, started, method, "callback");
119
+ }
120
+ report(failure, started, method);
121
+ if (context.feedbackOwner === "client") handleError(failure);
122
+ throw failure;
123
+ } finally {
124
+ if (timer !== void 0) clearTimeout(timer);
125
+ }
126
+ }
127
+ const get = (path, options) => request(path, { ...options, method: "GET" }).then((value) => value.data);
128
+ const post = (path, body, options) => request(path, { ...options, method: "POST", body }).then((value) => value.data);
129
+ const put = (path, body, options) => request(path, { ...options, method: "PUT", body }).then((value) => value.data);
130
+ const patch = (path, body, options) => request(path, { ...options, method: "PATCH", body }).then((value) => value.data);
131
+ const remove = (path, options) => request(path, { ...options, method: "DELETE" }).then((value) => value.data);
132
+ return { request, get, post, put, patch, delete: remove, handleError };
133
+ }
134
+ export {
135
+ ApiError,
136
+ createHttpClient,
137
+ parseHttpError
138
+ };
@@ -0,0 +1,2 @@
1
+ import type { RequestFailure } from "./types.js";
2
+ export declare function toSafeDiagnostics(error: unknown, context?: Pick<RequestFailure, "method" | "origin" | "elapsedMs" | "stage">): RequestFailure;
@@ -0,0 +1,9 @@
1
+ import { ApiError, isRequestCancelled } from "./errors.js";
2
+ function toSafeDiagnostics(error, context = {}) {
3
+ const safe = { method: context.method, origin: context.origin, elapsedMs: context.elapsedMs, stage: context.stage };
4
+ if (error instanceof ApiError) return { ...safe, kind: error.kind, status: error.status, code: error.code, requestId: error.requestId };
5
+ return { ...safe, kind: isRequestCancelled(error) ? "cancelled" : "unknown" };
6
+ }
7
+ export {
8
+ toSafeDiagnostics
9
+ };
@@ -0,0 +1,2 @@
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;
@@ -0,0 +1,45 @@
1
+ import { isRequestCancelled, ApiError, errorDefinitions } from "./errors.js";
2
+ import { toSafeDiagnostics } from "./diagnostics.js";
3
+ const handled = /* @__PURE__ */ new WeakSet();
4
+ function createErrorHandler(config) {
5
+ function callback(run) {
6
+ try {
7
+ run();
8
+ } catch (error) {
9
+ try {
10
+ config.onFailure?.(toSafeDiagnostics(error, { stage: "callback" }));
11
+ } catch {
12
+ }
13
+ }
14
+ }
15
+ return function handleError(error, input = {}) {
16
+ 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" });
18
+ const method = apiError.context?.method;
19
+ const context = {
20
+ intent: input.intent ?? (method === "GET" || method === "HEAD" ? "read" : "mutation"),
21
+ trigger: input.trigger ?? "user",
22
+ feedback: input.feedback ?? apiError.context?.feedback ?? "auto"
23
+ };
24
+ if (apiError.context?.authHandled || context.feedback !== "auto" || context.trigger === "background" || context.intent === "read" && context.trigger === "initial") return;
25
+ let businessHandled = false;
26
+ callback(() => {
27
+ businessHandled = config.onBusinessError?.(apiError, context) === true;
28
+ });
29
+ if (businessHandled) {
30
+ if (error && typeof error === "object") handled.add(error);
31
+ return;
32
+ }
33
+ if (!config.notify) return;
34
+ if (error && typeof error === "object") handled.add(error);
35
+ const uncertain = context.intent === "mutation" && (apiError.kind === "network" || apiError.kind === "timeout");
36
+ callback(() => config.notify?.({
37
+ message: uncertain ? "暂时无法确认操作结果,请保留输入并检查结果后再重试。" : apiError.message,
38
+ requestId: apiError.requestId,
39
+ id: apiError.kind === "network" || apiError.kind === "timeout" ? "http-network" : void 0
40
+ }));
41
+ };
42
+ }
43
+ export {
44
+ createErrorHandler
45
+ };
@@ -0,0 +1,37 @@
1
+ import type { FieldError, RequestContext } from "./types.js";
2
+ export declare const errorDefinitions: {
3
+ readonly NETWORK_ERROR: "网络连接暂时不可用,请稍后重试。";
4
+ readonly REQUEST_TIMEOUT: "请求超时,请稍后重试。";
5
+ readonly RESPONSE_INVALID: "服务返回的数据格式异常。";
6
+ readonly RESPONSE_TOO_LARGE: "返回内容超过读取限制。";
7
+ readonly REQUEST_INVALID_INPUT: "请求配置无效。";
8
+ readonly AUTH_UNAUTHENTICATED: "登录状态已失效,请重新登录。";
9
+ readonly REQUEST_FORBIDDEN: "没有执行此操作的权限。";
10
+ readonly INTERNAL_ERROR: "服务暂时无法完成请求,请稍后重试。";
11
+ readonly HTTP_ERROR: "请求未能完成,请检查后重试。";
12
+ };
13
+ export type ApiErrorKind = "network" | "timeout" | "http" | "response" | "input";
14
+ export type ApiErrorOptions<T = unknown> = {
15
+ status: number;
16
+ code: string;
17
+ message: string;
18
+ requestId?: string;
19
+ errors?: FieldError[];
20
+ details?: T;
21
+ kind?: ApiErrorKind;
22
+ retryAfterMs?: number;
23
+ context?: RequestContext;
24
+ };
25
+ export declare class ApiError<T = unknown> extends Error {
26
+ readonly status: number;
27
+ readonly code: string;
28
+ readonly requestId?: string;
29
+ readonly errors?: FieldError[];
30
+ readonly details?: T;
31
+ readonly kind: ApiErrorKind;
32
+ readonly retryAfterMs?: number;
33
+ readonly context?: RequestContext;
34
+ constructor(options: ApiErrorOptions<T>);
35
+ }
36
+ export declare function isRequestCancelled(error: unknown): boolean;
37
+ export declare const isApiError: (error: unknown) => error is ApiError;
package/dist/errors.js ADDED
@@ -0,0 +1,45 @@
1
+ const errorDefinitions = {
2
+ NETWORK_ERROR: "网络连接暂时不可用,请稍后重试。",
3
+ REQUEST_TIMEOUT: "请求超时,请稍后重试。",
4
+ RESPONSE_INVALID: "服务返回的数据格式异常。",
5
+ RESPONSE_TOO_LARGE: "返回内容超过读取限制。",
6
+ REQUEST_INVALID_INPUT: "请求配置无效。",
7
+ AUTH_UNAUTHENTICATED: "登录状态已失效,请重新登录。",
8
+ REQUEST_FORBIDDEN: "没有执行此操作的权限。",
9
+ INTERNAL_ERROR: "服务暂时无法完成请求,请稍后重试。",
10
+ HTTP_ERROR: "请求未能完成,请检查后重试。"
11
+ };
12
+ class ApiError extends Error {
13
+ status;
14
+ code;
15
+ requestId;
16
+ errors;
17
+ details;
18
+ kind;
19
+ retryAfterMs;
20
+ context;
21
+ constructor(options) {
22
+ super(options.message);
23
+ this.name = "ApiError";
24
+ this.status = options.status;
25
+ this.code = options.code;
26
+ this.requestId = options.requestId;
27
+ this.errors = options.errors;
28
+ this.details = options.details;
29
+ this.kind = options.kind ?? "http";
30
+ this.retryAfterMs = options.retryAfterMs;
31
+ this.context = options.context;
32
+ }
33
+ }
34
+ function isRequestCancelled(error) {
35
+ if (error instanceof ApiError) return false;
36
+ if (!error || typeof error !== "object") return false;
37
+ return "name" in error && error.name === "AbortError" || "code" in error && error.code === "ERR_CANCELED";
38
+ }
39
+ const isApiError = (error) => error instanceof ApiError;
40
+ export {
41
+ ApiError,
42
+ errorDefinitions,
43
+ isApiError,
44
+ isRequestCancelled
45
+ };
@@ -0,0 +1,3 @@
1
+ export { ApiError, errorDefinitions, isApiError, isRequestCancelled } from "./errors.js";
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";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ import { ApiError, errorDefinitions, isApiError, isRequestCancelled } from "./errors.js";
2
+ export {
3
+ ApiError,
4
+ errorDefinitions,
5
+ isApiError,
6
+ isRequestCancelled
7
+ };
@@ -0,0 +1,23 @@
1
+ import { QueryClient } from "@tanstack/react-query";
2
+ import { toSafeDiagnostics } from "./diagnostics.js";
3
+ import type { ErrorContext, Feedback } from "./types.js";
4
+ export type HttpQueryMeta = {
5
+ feedback?: Feedback;
6
+ successMessage?: string;
7
+ };
8
+ export type HttpQueryClientConfig = {
9
+ handleError: (error: unknown, context?: Partial<ErrorContext>) => void;
10
+ queries?: {
11
+ staleTime?: number;
12
+ maxRetries?: number;
13
+ retryDelay?: (attempt: number, error: unknown) => number;
14
+ refetchOnWindowFocus?: boolean;
15
+ refetchOnReconnect?: boolean;
16
+ };
17
+ notifySuccess?: (message: string) => void;
18
+ onMutationSuccess?: (meta: Readonly<Record<string, unknown>> | undefined) => void;
19
+ onFailure?: (failure: ReturnType<typeof toSafeDiagnostics>) => void;
20
+ };
21
+ export declare function shouldRetryRequest(failureCount: number, error: unknown, maxRetries?: number): boolean;
22
+ export declare function requestRetryDelay(attempt: number, error: unknown): number;
23
+ export declare function createHttpQueryClient(config: HttpQueryClientConfig): QueryClient;
package/dist/query.js ADDED
@@ -0,0 +1,68 @@
1
+ import { QueryClient, MutationCache, QueryCache, isCancelledError } from "@tanstack/react-query";
2
+ import { isRequestCancelled, ApiError } from "./errors.js";
3
+ import { toSafeDiagnostics } from "./diagnostics.js";
4
+ function shouldRetryRequest(failureCount, error, maxRetries = 2) {
5
+ if (failureCount >= maxRetries || !(error instanceof ApiError)) return false;
6
+ if (error.context?.method !== "GET" && error.context?.method !== "HEAD") return false;
7
+ return error.kind === "network" || error.kind === "timeout" || error.kind === "http" && [429, 502, 503, 504].includes(error.status);
8
+ }
9
+ function requestRetryDelay(attempt, error) {
10
+ const requested = error instanceof ApiError ? error.retryAfterMs : void 0;
11
+ return requested === void 0 ? Math.min(1e3 * 2 ** attempt, 3e4) : Math.min(Math.max(0, requested), 3e4);
12
+ }
13
+ function createHttpQueryClient(config) {
14
+ function callback(run) {
15
+ try {
16
+ run();
17
+ } catch (error) {
18
+ try {
19
+ config.onFailure?.(toSafeDiagnostics(error, { stage: "callback" }));
20
+ } catch {
21
+ }
22
+ }
23
+ }
24
+ const retries = config.queries?.maxRetries ?? 2;
25
+ if (!Number.isSafeInteger(retries) || retries < 0) throw new RangeError("maxRetries must be a non-negative integer");
26
+ const queryOptions = {
27
+ staleTime: config.queries?.staleTime ?? 1e4,
28
+ retry: (count, error) => shouldRetryRequest(count, error, retries),
29
+ retryDelay: (attempt, error) => {
30
+ let delay = requestRetryDelay(attempt, error);
31
+ callback(() => {
32
+ delay = config.queries?.retryDelay?.(attempt, error) ?? delay;
33
+ });
34
+ return Number.isFinite(delay) ? Math.max(0, Math.min(delay, 3e4)) : requestRetryDelay(attempt, error);
35
+ },
36
+ refetchOnWindowFocus: config.queries?.refetchOnWindowFocus ?? true,
37
+ refetchOnReconnect: config.queries?.refetchOnReconnect ?? true,
38
+ networkMode: "online"
39
+ };
40
+ return new QueryClient({
41
+ queryCache: new QueryCache({
42
+ onError: (error, query) => {
43
+ if (isCancelledError(error) || isRequestCancelled(error)) return;
44
+ const meta = query.meta;
45
+ callback(() => config.handleError(error, { intent: "read", trigger: query.state.data === void 0 ? "initial" : "background", ...meta?.feedback ? { feedback: meta.feedback } : {} }));
46
+ }
47
+ }),
48
+ mutationCache: new MutationCache({
49
+ onError: (error, _variables, _context, mutation) => {
50
+ if (isCancelledError(error) || isRequestCancelled(error)) return;
51
+ const meta = mutation.meta;
52
+ callback(() => config.handleError(error, { intent: "mutation", trigger: "user", ...meta?.feedback ? { feedback: meta.feedback } : {} }));
53
+ },
54
+ onSuccess: (_data, _variables, _context, mutation) => {
55
+ callback(() => config.onMutationSuccess?.(mutation.meta));
56
+ const meta = mutation.meta;
57
+ const message = meta?.successMessage;
58
+ if (message) callback(() => config.notifySuccess?.(message));
59
+ }
60
+ }),
61
+ defaultOptions: { queries: queryOptions, mutations: { retry: false, networkMode: "online" } }
62
+ });
63
+ }
64
+ export {
65
+ createHttpQueryClient,
66
+ requestRetryDelay,
67
+ shouldRetryRequest
68
+ };
@@ -0,0 +1,7 @@
1
+ import { ApiError } from "./errors.js";
2
+ import type { HttpClientConfig, ResponseInfo, ResponseMode } from "./types.js";
3
+ export declare function parseRetryAfter(value: string | undefined): number | undefined;
4
+ export declare function parseHttpError(data: unknown, info: ResponseInfo): ApiError;
5
+ export declare function readBody(body: ReadableStream<Uint8Array> | null, info: ResponseInfo, limit: number): Promise<Uint8Array[]>;
6
+ export declare function decodeBody(chunks: Uint8Array[], mode: ResponseMode, info: ResponseInfo, config: HttpClientConfig): unknown;
7
+ export declare function errorBody(chunks: Uint8Array[]): unknown;
@@ -0,0 +1,97 @@
1
+ import { ApiError, errorDefinitions } from "./errors.js";
2
+ const record = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
3
+ function parseRetryAfter(value) {
4
+ if (!value) return void 0;
5
+ const seconds = Number(value);
6
+ const milliseconds = /^\d+(?:\.\d+)?$/.test(value) ? seconds * 1e3 : Date.parse(value) - Date.now();
7
+ return Number.isFinite(milliseconds) ? Math.max(0, Math.min(milliseconds, 3e4)) : void 0;
8
+ }
9
+ function parseHttpError(data, info) {
10
+ const body = record(data) ? data : {};
11
+ const valid = typeof body.code === "string" && /^[A-Z][A-Z0-9_]{0,127}$/.test(body.code) && typeof body.errMsg === "string";
12
+ 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
+ });
21
+ }
22
+ async function readBody(body, info, limit) {
23
+ if (!body) return [];
24
+ const reader = body.getReader();
25
+ const chunks = [];
26
+ let bytes = 0;
27
+ 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"] });
29
+ let chunk = await reader.read();
30
+ while (!chunk.done) {
31
+ const value = chunk.value;
32
+ 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"] });
34
+ chunks.push(value);
35
+ chunk = await reader.read();
36
+ }
37
+ return chunks;
38
+ } catch (error) {
39
+ try {
40
+ await reader.cancel();
41
+ } catch {
42
+ }
43
+ throw error;
44
+ } finally {
45
+ reader.releaseLock();
46
+ }
47
+ }
48
+ function decodeBody(chunks, mode, info, config) {
49
+ const bytes = bodyBytes(chunks);
50
+ let value;
51
+ if (mode === "blob") value = new Blob([bytes], { type: info.headers["content-type"] ?? "application/octet-stream" });
52
+ else {
53
+ const text = new TextDecoder().decode(bytes);
54
+ if (mode === "text") value = text;
55
+ else {
56
+ try {
57
+ value = text ? JSON.parse(text) : void 0;
58
+ } catch {
59
+ throw new ApiError({ status: info.status, code: "RESPONSE_INVALID", message: errorDefinitions.RESPONSE_INVALID, kind: "response", requestId: info.headers["x-request-id"] });
60
+ }
61
+ }
62
+ }
63
+ if (config.decodeResponse) {
64
+ try {
65
+ return config.decodeResponse(value, info);
66
+ } catch (error) {
67
+ 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"] });
69
+ }
70
+ }
71
+ if (mode !== "envelope") return value;
72
+ 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"] });
74
+ }
75
+ function errorBody(chunks) {
76
+ try {
77
+ return JSON.parse(new TextDecoder().decode(bodyBytes(chunks)));
78
+ } catch {
79
+ return void 0;
80
+ }
81
+ }
82
+ function bodyBytes(chunks) {
83
+ const bytes = new Uint8Array(chunks.reduce((sum, value) => sum + value.byteLength, 0));
84
+ let offset = 0;
85
+ for (const chunk of chunks) {
86
+ bytes.set(chunk, offset);
87
+ offset += chunk.byteLength;
88
+ }
89
+ return bytes;
90
+ }
91
+ export {
92
+ decodeBody,
93
+ errorBody,
94
+ parseHttpError,
95
+ parseRetryAfter,
96
+ readBody
97
+ };
@@ -0,0 +1,4 @@
1
+ export { ok, fail, toErrorResponse } from "./response.js";
2
+ export type { ServerResponseOptions, HttpFailureInput } from "./response.js";
3
+ export { createServiceRequest, verifyServiceToken, SERVICE_TOKEN_HEADER } from "./service-auth.js";
4
+ export { toSafeDiagnostics } from "../diagnostics.js";
@@ -0,0 +1,12 @@
1
+ import { fail, ok, toErrorResponse } from "./response.js";
2
+ import { SERVICE_TOKEN_HEADER, createServiceRequest, verifyServiceToken } from "./service-auth.js";
3
+ import { toSafeDiagnostics } from "../diagnostics.js";
4
+ export {
5
+ SERVICE_TOKEN_HEADER,
6
+ createServiceRequest,
7
+ fail,
8
+ ok,
9
+ toErrorResponse,
10
+ toSafeDiagnostics,
11
+ verifyServiceToken
12
+ };
@@ -0,0 +1,18 @@
1
+ import type { FieldError } from "../types.js";
2
+ export type ServerResponseOptions = {
3
+ status?: number;
4
+ headers?: NonNullable<RequestInit["headers"]>;
5
+ requestId?: string;
6
+ };
7
+ export type HttpFailureInput<T = unknown> = {
8
+ status: number;
9
+ code: string;
10
+ message: string;
11
+ requestId?: string;
12
+ errors?: FieldError[];
13
+ details?: T;
14
+ headers?: NonNullable<RequestInit["headers"]>;
15
+ };
16
+ export declare function ok<T>(data: T, options?: ServerResponseOptions): Response;
17
+ export declare function fail<T>(input: HttpFailureInput<T>): Response;
18
+ export declare function toErrorResponse(error: unknown, options?: Omit<ServerResponseOptions, "status">): Response;
@@ -0,0 +1,31 @@
1
+ import { ApiError, errorDefinitions } from "../errors.js";
2
+ function responseHeaders(options) {
3
+ const requestId = options.requestId ?? crypto.randomUUID();
4
+ const headers = new Headers(options.headers);
5
+ headers.set("X-Request-ID", requestId);
6
+ return { headers, requestId };
7
+ }
8
+ function ok(data, options = {}) {
9
+ const status = options.status ?? 200;
10
+ if (![200, 201, 202].includes(status)) throw new RangeError("ok supports 200, 201 and 202");
11
+ const { headers } = responseHeaders(options);
12
+ const body = { success: true, msg: data, errMsg: "", code: "" };
13
+ return Response.json(body, { status, headers });
14
+ }
15
+ function fail(input) {
16
+ if (!Number.isInteger(input.status) || input.status < 400 || input.status > 599 || ["NETWORK_ERROR", "REQUEST_TIMEOUT", "RESPONSE_TOO_LARGE"].includes(input.code) || !/^[A-Z][A-Z0-9_]{0,127}$/.test(input.code)) throw new RangeError("Invalid HTTP failure contract");
17
+ const { headers, requestId } = responseHeaders(input);
18
+ const body = { code: input.code, errMsg: input.message, requestId, ...input.errors ? { errors: input.errors } : {}, ...input.details === void 0 ? {} : { details: input.details } };
19
+ return Response.json(body, { status: input.status, headers });
20
+ }
21
+ function toErrorResponse(error, options = {}) {
22
+ 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
+ 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
+ return fail({ status: 500, code: "INTERNAL_ERROR", message: errorDefinitions.INTERNAL_ERROR, ...options });
26
+ }
27
+ export {
28
+ fail,
29
+ ok,
30
+ toErrorResponse
31
+ };
@@ -0,0 +1,3 @@
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;
3
+ export declare function verifyServiceToken(provided: string | null | undefined, expected: string): Promise<boolean>;
@@ -0,0 +1,24 @@
1
+ import { boundUrl, serviceUrl } from "../url.js";
2
+ const SERVICE_TOKEN_HEADER = "X-SpringBrand-Service-Token";
3
+ function createServiceRequest(baseUrl, token, path, init = {}) {
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");
8
+ headers.delete("Cookie");
9
+ headers.set(SERVICE_TOKEN_HEADER, token);
10
+ return new Request(url, { ...init, headers, credentials: "omit", redirect: "error" });
11
+ }
12
+ async function verifyServiceToken(provided, expected) {
13
+ if (!provided || !expected) return false;
14
+ const encoder = new TextEncoder();
15
+ const [left, right] = await Promise.all([provided, expected].map((value) => crypto.subtle.digest("SHA-256", encoder.encode(value)).then((buffer) => new Uint8Array(buffer))));
16
+ let difference = 0;
17
+ for (let i = 0; i < left.length; i++) difference |= left[i] ^ right[i];
18
+ return difference === 0;
19
+ }
20
+ export {
21
+ SERVICE_TOKEN_HEADER,
22
+ createServiceRequest,
23
+ verifyServiceToken
24
+ };
@@ -0,0 +1,98 @@
1
+ import type { ApiError } from "./errors.js";
2
+ export type HttpMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS";
3
+ export type ResponseMode = "envelope" | "json" | "text" | "blob";
4
+ export type Feedback = "auto" | "inline" | "silent";
5
+ export type FeedbackOwner = "client" | "query";
6
+ export type FieldError = {
7
+ path: string;
8
+ errMsg: string;
9
+ };
10
+ export type ApiSuccessResponse<T> = {
11
+ success: true;
12
+ msg: T;
13
+ errMsg: "";
14
+ code: "";
15
+ };
16
+ export type HttpErrorResponse<T = unknown> = {
17
+ code: string;
18
+ errMsg: string;
19
+ requestId?: string;
20
+ errors?: FieldError[];
21
+ details?: T;
22
+ };
23
+ export type HttpResult<T> = {
24
+ data: T;
25
+ status: number;
26
+ headers: Readonly<Record<string, string>>;
27
+ };
28
+ export type ErrorContext = {
29
+ intent: "read" | "mutation";
30
+ trigger: "initial" | "background" | "user";
31
+ feedback: Feedback;
32
+ };
33
+ export type RequestContext = {
34
+ method: HttpMethod;
35
+ feedback: Feedback;
36
+ feedbackOwner: FeedbackOwner;
37
+ authHandled?: boolean;
38
+ };
39
+ export type RequestNotice = {
40
+ message: string;
41
+ requestId?: string;
42
+ id?: string;
43
+ };
44
+ export type RequestFailure = {
45
+ kind: string;
46
+ status?: number;
47
+ code?: string;
48
+ requestId?: string;
49
+ method?: HttpMethod;
50
+ origin?: string;
51
+ elapsedMs?: number;
52
+ stage?: "request" | "callback";
53
+ };
54
+ export type ResponseInfo = {
55
+ status: number;
56
+ headers: Readonly<Record<string, string>>;
57
+ };
58
+ export type QueryScalar = string | number | boolean | null | undefined;
59
+ export type UploadProgress = {
60
+ loaded: number;
61
+ total?: number;
62
+ progress?: number;
63
+ };
64
+ export type HttpRequestOptions = {
65
+ method?: HttpMethod;
66
+ body?: unknown;
67
+ params?: Record<string, QueryScalar | readonly QueryScalar[]>;
68
+ headers?: NonNullable<RequestInit["headers"]>;
69
+ signal?: AbortSignal;
70
+ timeoutMs?: number;
71
+ maxResponseBytes?: number;
72
+ auth?: boolean;
73
+ handleUnauthorized?: boolean;
74
+ responseMode?: ResponseMode;
75
+ feedback?: Feedback;
76
+ feedbackOwner?: FeedbackOwner;
77
+ idempotencyKey?: string;
78
+ onUploadProgress?: (progress: UploadProgress) => void;
79
+ };
80
+ export type HttpClientConfig = {
81
+ baseUrl: string;
82
+ timeoutMs?: number;
83
+ maxResponseBytes?: number;
84
+ credentials?: NonNullable<RequestInit["credentials"]>;
85
+ getToken?: () => string | null | undefined;
86
+ getHeaders?: () => NonNullable<RequestInit["headers"]>;
87
+ responseMode?: ResponseMode;
88
+ decodeResponse?: (data: unknown, info: ResponseInfo) => unknown;
89
+ decodeError?: (data: unknown, info: ResponseInfo) => ApiError;
90
+ isUnauthorized?: (error: ApiError) => boolean;
91
+ onUnauthorized?: (error: ApiError) => void;
92
+ notify?: (notice: RequestNotice) => void;
93
+ onBusinessError?: (error: ApiError, context: ErrorContext) => boolean;
94
+ feedbackOwner?: FeedbackOwner;
95
+ onFailure?: (failure: RequestFailure) => void;
96
+ /** 注入标准 Fetch,便于测试或适配宿主;请求仍由 Axios 执行。 */
97
+ fetch?: typeof globalThis.fetch;
98
+ };
package/dist/url.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare function serviceUrl(baseUrl: string): URL;
2
+ export declare function boundUrl(base: URL, path: string | URL): URL;
package/dist/url.js ADDED
@@ -0,0 +1,31 @@
1
+ import { ApiError, errorDefinitions } from "./errors.js";
2
+ function invalidUrl() {
3
+ throw new ApiError({ status: 0, code: "REQUEST_INVALID_INPUT", message: errorDefinitions.REQUEST_INVALID_INPUT, kind: "input" });
4
+ }
5
+ function serviceUrl(baseUrl) {
6
+ try {
7
+ const origin = typeof window === "undefined" ? void 0 : window.location.origin;
8
+ if (/^https?:/i.test(baseUrl) && !/^https?:\/\//i.test(baseUrl)) return invalidUrl();
9
+ const url = new URL(baseUrl, origin);
10
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) return invalidUrl();
11
+ return url;
12
+ } catch {
13
+ return invalidUrl();
14
+ }
15
+ }
16
+ function boundUrl(base, path) {
17
+ try {
18
+ const value = String(path);
19
+ const absolute = /^[a-z][a-z\d+.-]*:/i.test(value) || value.startsWith("//");
20
+ if (/^https?:/i.test(value) && !/^https?:\/\//i.test(value)) return invalidUrl();
21
+ const url = absolute ? new URL(value, base) : new URL(`${base.pathname.replace(/\/$/, "")}/${value.replace(/^\/+/, "")}`, base.origin);
22
+ if (url.origin !== base.origin || url.username || url.password || url.hash) return invalidUrl();
23
+ return url;
24
+ } catch {
25
+ return invalidUrl();
26
+ }
27
+ }
28
+ export {
29
+ boundUrl,
30
+ serviceUrl
31
+ };
package/package.json CHANGED
@@ -1,6 +1,73 @@
1
1
  {
2
2
  "name": "@springbrand/http",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-alpha.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public",
8
+ "tag": "alpha",
9
+ "registry": "https://registry.npmjs.org/"
10
+ },
11
+ "sideEffects": false,
12
+ "main": "./dist/index.js",
13
+ "module": "./dist/index.js",
14
+ "typings": "./dist/index.d.ts",
15
+ "files": [
16
+ "dist",
17
+ ".agents",
18
+ "README.md"
19
+ ],
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js",
24
+ "default": "./dist/index.js"
25
+ },
26
+ "./errors": {
27
+ "types": "./dist/errors.d.ts",
28
+ "import": "./dist/errors.js",
29
+ "default": "./dist/errors.js"
30
+ },
31
+ "./client": {
32
+ "types": "./dist/client.d.ts",
33
+ "import": "./dist/client.js",
34
+ "default": "./dist/client.js"
35
+ },
36
+ "./query": {
37
+ "types": "./dist/query.d.ts",
38
+ "import": "./dist/query.js",
39
+ "default": "./dist/query.js"
40
+ },
41
+ "./server": {
42
+ "types": "./dist/server/index.d.ts",
43
+ "import": "./dist/server/index.js",
44
+ "default": "./dist/server/index.js"
45
+ },
46
+ "./package.json": "./package.json"
47
+ },
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
+ "dependencies": {
56
+ "axios": "1.20.0"
57
+ },
58
+ "peerDependencies": {
59
+ "@tanstack/react-query": "^5.104.1"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "@tanstack/react-query": {
63
+ "optional": true
64
+ }
65
+ },
66
+ "devDependencies": {
67
+ "@tanstack/react-query": "5.104.1",
68
+ "vitest": "3.2.6",
69
+ "typescript": "^5.0.2",
70
+ "vite": "^5.4.19",
71
+ "@types/node": "^20.8.3"
72
+ }
73
+ }