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

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.
@@ -17,7 +17,7 @@ Read the installed declarations and README before configuring a client. Public e
17
17
 
18
18
  ## Client setup
19
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.
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, no-store cache, manual redirects, 15-second deadline and 10-MiB response limit. The Fetch adapter supports browsers, Node.js 24 and Cloudflare Workers. Redirects reject with REQUEST_REDIRECT_BLOCKED, including opaque browser redirects; never enable automatic redirects for credential-bearing API calls.
21
21
 
22
22
  Pass params for query serialization and signal/timeoutMs for cancellation/deadlines. Null/undefined query values are omitted; use a string for literal null. Do not duplicate query encoding or timeout controllers in domain clients.
23
23
 
@@ -31,14 +31,16 @@ Choose one feedback owner: client for direct calls, query for Query/Mutation. In
31
31
 
32
32
  Use isTransientHttpError from the core or errors entry point for cached-view recovery, matching the Query retry classifier. Network, timeout and HTTP 429/502/503/504 are transient; cancellation, decoding, auth and permission errors are not. Classification never authorizes replaying writes.
33
33
 
34
+ Runtime capability failures use RUNTIME_UNSUPPORTED (kind runtime), distinct from invalid input; neither runtime failures nor blocked redirects are transient or retryable. Raw runtime diagnostics are not exposed.
35
+
34
36
  Only confirmed current Bearer session failures trigger onUnauthorized. Never clear sessions on network failures, every 401, or 403. SDK callbacks guard stale Token responses and duplicate feedback. Cookie login/recovery belongs to consumer authentication.
35
37
 
36
38
  ## Server setup
37
39
 
38
40
  Return ok or fail through framework-compatible Response handling. Failures use real 4xx/5xx and public error messages. Keep domain errors separate until the HTTP handler translates them. Validate business details before returning them. toErrorResponse hides unknown exceptions.
39
41
 
40
- Use service request/verification helpers for service credentials, independently of user authentication. Never forward user Cookie/Authorization or send service tokens to another origin.
42
+ Use service request/verification helpers for service credentials, independently of user authentication. Service requests use manual redirects and no-store; Workers omit the unsupported credentials option. The caller must reject 3xx instead of following them with service credentials. Never forward user Cookie/Authorization or send service tokens to another origin.
41
43
 
42
44
  ## Verification
43
45
 
44
- Exercise transient read recovery, write uncertainty, field errors, cancellation, Token changes and callback failures. Verify server imports without React Query and packaged public entry points. Use HTTP Storybook examples as behavior references, not copied implementations. Do not add app-local parallel clients or edit installed node_modules.
46
+ Exercise transient read recovery, write uncertainty, field errors, cancellation, Token changes and callback failures. Run the Miniflare/workerd regression with mocked outbound services; Node-only Fetch tests do not prove Worker compatibility. Verify server imports without React Query and packaged public entry points. Use HTTP Storybook examples as behavior references, not copied implementations. Do not add app-local parallel clients or edit installed node_modules.
package/README.md CHANGED
@@ -39,7 +39,7 @@ const items = await api.get<Item[]>('/items');
39
39
  await api.post('/items', { name: 'Example' });
40
40
  ```
41
41
 
42
- Token 每次发送时读取。实例只向绑定来源发送请求;非法地址、带用户名密码的 URL 和其他来源的绝对 URL 会在发送前失败。`baseUrl` 的路径前缀会与相对接口路径拼接。
42
+ Token 每次发送时读取。实例使用 `cache: no-store` 和 `redirect: manual`,兼容浏览器、Node.js 24 和 Cloudflare Workers。API 重定向(含浏览器 opaque redirect)以 REQUEST_REDIRECT_BLOCKED 拒绝,不跟随跳转;Query 缓存仍由 Query 管理。实例只向绑定来源发送请求;非法地址、带用户名密码的 URL 和其他来源的绝对 URL 会在发送前失败。`baseUrl` 的路径前缀会与相对接口路径拼接。
43
43
 
44
44
  客户端使用 Axios Fetch Adapter,默认 `credentials: 'omit'`。Cookie 登录使用显式 `credentials: 'include'` 的独立实例。`auth: false` 关闭 SDK 的 Bearer 注入,Cookie 策略不随之改变。有 getToken 时,SDK 管理 Authorization,额外请求头不能覆盖它。
45
45
 
@@ -119,7 +119,7 @@ if (query.data !== undefined && isTransientHttpError(query.error)) {
119
119
 
120
120
  查询参数直接传 `params`,由 SDK 编码:数组发送重复参数,null 和 undefined 省略。需要字面值 null 的业务字段应传字符串 `'null'`。传入 `signal` 和 `timeoutMs` 即可取消或设置超时,无需在业务包再创建超时控制器。
121
121
 
122
- 主动写入的网络失败会提示结果暂时无法确认,不自动重发。取消保持取消语义,不包装成 NETWORK_ERROR。超时为 REQUEST_TIMEOUT,格式错误为 RESPONSE_INVALID,内容超限为 RESPONSE_TOO_LARGE。
122
+ 主动写入的网络失败会提示结果暂时无法确认,不自动重发。取消保持取消语义,不包装成 NETWORK_ERROR。超时为 REQUEST_TIMEOUT,格式错误为 RESPONSE_INVALID,内容超限为 RESPONSE_TOO_LARGE。请求运行时不支持所需能力时为 RUNTIME_UNSUPPORTED(kind: runtime),与非法调用参数 REQUEST_INVALID_INPUT 分开;运行时和重定向错误不重试、不清理登录。
123
123
 
124
124
  默认仅 401 且 AUTH_UNAUTHENTICATED 触发失效回调。旧身份的响应不清理新身份,同一 Token 并发失效仅处理一次;网络错误、其他 401 和 403 不退出登录。默认自动处理针对实例携带的 Bearer 身份,Cookie 会话的恢复流程由消费者掌握。
125
125
 
@@ -149,7 +149,7 @@ ok 支持 200、201、202;无内容成功直接用标准 Response。fail 只
149
149
 
150
150
  toErrorResponse 公开标准 ApiError 的安全信息,未知异常返回固定 500 INTERNAL_ERROR。业务错误和 details 在 HTTP 处理器中翻译和验证。
151
151
 
152
- createServiceRequest 为绑定来源构造服务请求,清理用户 Authorization 和 Cookie,注入 X-SpringBrand-Service-Token。verifyServiceToken 比较等长摘要。服务请求不跟随重定向,不使用用户 Bearer 的登录失效流程。
152
+ createServiceRequest 为绑定来源构造服务请求,清理用户 Authorization 和 Cookie,注入 X-SpringBrand-Service-Token。verifyServiceToken 比较等长摘要。服务请求使用 no-store / manual,Worker 中不设置浏览器专有的 credentials。调用方需拒绝非成功响应,尤其不得自行跟随 3xx 并转发服务凭据;不使用用户 Bearer 的登录失效流程。
153
153
 
154
154
  ## 验证与示例
155
155
 
@@ -161,6 +161,6 @@ pnpm --filter @springbrand/http build
161
161
  pnpm build-storybook
162
162
  ```
163
163
 
164
- Storybook 的 HTTP 分类包含成功、后台恢复、首次失败、字段错误、业务扩展、认证、取消、动态 Token 和上传下载。模拟服务仍通过真实 Axios 请求实现。
164
+ Storybook 的 HTTP 分类包含成功、后台恢复、首次失败、字段错误、业务扩展、认证、取消、动态 Token 和上传下载。模拟服务仍通过真实 Axios 请求实现。tests/worker.test.ts 使用 Miniflare/workerd 执行真正的 Worker,验证 JSON、Token、服务凭据及重定向保护;测试不会访问真实供应商。
165
165
 
166
166
  完整设计位于 dune-react 的 trevordocs/axios-request-sdk-architecture.md。预发布版本使用 npm 的 alpha 标签,业务项目的迁移由各项目独立安排。
package/dist/client.js CHANGED
@@ -13,7 +13,8 @@ function createHttpClient(config) {
13
13
  responseType: "stream",
14
14
  timeout: 0,
15
15
  withCredentials: config.credentials === "same-origin" ? void 0 : config.credentials === "include",
16
- fetchOptions: { redirect: "error" }
16
+ // Workers rejects explicit cache: default and redirect: error. Never follow API redirects.
17
+ fetchOptions: { cache: "no-store", redirect: "manual" }
17
18
  });
18
19
  const handleError = createErrorHandler(config);
19
20
  let rejectedToken;
@@ -75,6 +76,13 @@ function createHttpClient(config) {
75
76
  const rawHeaders = response.headers instanceof axios.AxiosHeaders ? response.headers.toJSON() : response.headers;
76
77
  for (const [key, value] of Object.entries(rawHeaders)) if (typeof value === "string" || typeof value === "number" || Array.isArray(value)) responseHeaders[key.toLowerCase()] = String(value);
77
78
  const info = { status: response.status, headers: responseHeaders };
79
+ if (response.status === 0 || response.status >= 300 && response.status < 400) {
80
+ try {
81
+ await response.data?.cancel();
82
+ } catch {
83
+ }
84
+ throw new ApiError({ status: response.status, code: "REQUEST_REDIRECT_BLOCKED", message: errorDefinitions.REQUEST_REDIRECT_BLOCKED, kind: "response", requestId: responseHeaders["x-request-id"] });
85
+ }
78
86
  const chunks = await readBody(response.data, info, limit);
79
87
  if (signal.aborted) throw signal.reason;
80
88
  if (response.status < 200 || response.status >= 300) {
@@ -97,11 +105,15 @@ function createHttpClient(config) {
97
105
  const reason = signal.aborted ? signal.reason : error;
98
106
  if (signal.aborted && !(reason instanceof ApiError) && !(reason instanceof Error && reason.name === "TimeoutError")) throw reason;
99
107
  if (isRequestCancelled(reason)) throw reason;
108
+ const runtimeUnsupported = axios.isAxiosError(reason) && (reason.code === axios.AxiosError.ERR_NOT_SUPPORT || !reason.request && reason.cause instanceof TypeError);
109
+ const timedOut = reason instanceof Error && reason.name === "TimeoutError";
110
+ const networkFailure = received || axios.isAxiosError(reason) && reason.request instanceof Request;
111
+ const code = timedOut ? "REQUEST_TIMEOUT" : runtimeUnsupported ? "RUNTIME_UNSUPPORTED" : networkFailure ? "NETWORK_ERROR" : "REQUEST_INVALID_INPUT";
100
112
  const normalized = reason instanceof ApiError ? reason : new ApiError({
101
113
  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"
114
+ code,
115
+ message: errorDefinitions[code],
116
+ kind: timedOut ? "timeout" : runtimeUnsupported ? "runtime" : networkFailure ? "network" : "input"
105
117
  });
106
118
  const failure = new ApiError({ ...normalized, message: normalized.message, context });
107
119
  const unauthorized = config.isUnauthorized ?? ((value) => value.status === 401 && value.code === "AUTH_UNAUTHENTICATED");
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;
package/dist/errors.js CHANGED
@@ -4,6 +4,8 @@ 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: "服务暂时无法完成请求,请稍后重试。",
@@ -7,7 +7,10 @@ function createServiceRequest(baseUrl, token, path, init = {}) {
7
7
  headers.delete("Authorization");
8
8
  headers.delete("Cookie");
9
9
  headers.set(SERVICE_TOKEN_HEADER, token);
10
- return new Request(url, { ...init, headers, credentials: "omit", redirect: "error" });
10
+ const options = { ...init, headers, cache: "no-store", redirect: "manual" };
11
+ if ("credentials" in Request.prototype) options.credentials = "omit";
12
+ else delete options.credentials;
13
+ return new Request(url, options);
11
14
  }
12
15
  async function verifyServiceToken(provided, expected) {
13
16
  if (!provided || !expected) return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/http",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -65,9 +65,11 @@
65
65
  },
66
66
  "devDependencies": {
67
67
  "@tanstack/react-query": "5.104.1",
68
- "vitest": "3.2.6",
68
+ "@types/node": "^20.8.3",
69
+ "esbuild": "0.27.7",
70
+ "miniflare": "5.20260910.0-alpha",
69
71
  "typescript": "^5.0.2",
70
72
  "vite": "^5.4.19",
71
- "@types/node": "^20.8.3"
73
+ "vitest": "3.2.6"
72
74
  }
73
75
  }