@cnbcool/cnb-api-generate 2.21.3 → 2.21.4

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/client/core.ts CHANGED
@@ -5,6 +5,8 @@ import { fetchResponseHandler } from "./fetch-response-handler";
5
5
  import { generateUniqueId } from './utils/generate-unique-id';
6
6
  import { resolveToken } from './utils/resolve-token';
7
7
  import { resolveApiDomain } from './utils/resolve-api-domain';
8
+ import { wrapFetchError } from './utils/wrap-fetch-error';
9
+ import { describeFetchError, isNetworkError } from './utils/fetch-error';
8
10
 
9
11
  /**
10
12
  * 将 buffer 写入临时文件并返回文件路径
@@ -128,6 +130,27 @@ async function formatResponse(data: any, response: any) {
128
130
  return responseData;
129
131
  }
130
132
 
133
+ /**
134
+ * 网络层失败时给出一条能定位问题的错误响应,而不是把 `TypeError: fetch failed` 抛出去。
135
+ * 输出结构与正常响应保持一致(含 status / data.error),下游退出码逻辑照常生效。
136
+ * 错误文案直接用底层原始信息,不另做翻译。
137
+ */
138
+ function buildNetworkErrorResponse(err: any, url: string) {
139
+ return {
140
+ status: 0,
141
+ error: {
142
+ reason: describeFetchError(err),
143
+ message: err?.message || String(err),
144
+ url,
145
+ },
146
+ data: {
147
+ error: describeFetchError(err),
148
+ message: err?.message || String(err),
149
+ request_url: url,
150
+ },
151
+ };
152
+ }
153
+
131
154
  async function clientFetch(data: any): Promise<any> {
132
155
  const token = await resolveToken();
133
156
  const domain = resolveApiDomain()
@@ -140,17 +163,24 @@ async function clientFetch(data: any): Promise<any> {
140
163
  }
141
164
  }
142
165
 
143
- const response = await fetch(urlParse.href, {
144
- method: data.method.toUpperCase(),
145
- body: data.data ? JSON.stringify(data.data) : undefined,
146
- headers: {
147
- Authorization: `Bearer ${token}`,
148
- Accept: 'application/vnd.cnb.api+json',
149
- // 当请求体存在时,附加 Content-Type,避免后端无法识别 JSON body
150
- ...(data.data ? { 'Content-Type': 'application/json' } : {}),
151
- ...(data?.header || {}),
152
- },
153
- });
166
+ let response: Response;
167
+ try {
168
+ response = await wrapFetchError(fetch)(urlParse.href, {
169
+ method: data.method.toUpperCase(),
170
+ body: data.data ? JSON.stringify(data.data) : undefined,
171
+ headers: {
172
+ Authorization: `Bearer ${token}`,
173
+ Accept: 'application/vnd.cnb.api+json',
174
+ // 当请求体存在时,附加 Content-Type,避免后端无法识别 JSON body
175
+ ...(data.data ? { 'Content-Type': 'application/json' } : {}),
176
+ ...(data?.header || {}),
177
+ },
178
+ });
179
+ } catch (err) {
180
+ if (!isNetworkError(err)) throw err;
181
+ // 不打印到 stderr(避免与格式化输出混在一起),直接把原因放进响应体
182
+ return buildNetworkErrorResponse(err, urlParse.href);
183
+ }
154
184
 
155
185
  return await formatResponse(data, response);
156
186
  }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { resolveToken } from '../utils/resolve-token';
10
10
  import { resolveApiDomain } from '../utils/resolve-api-domain';
11
+ import { wrapFetchError } from '../utils/wrap-fetch-error';
11
12
 
12
13
  /** CNB 构建号格式:cnb-xxx-xxxxxxxxx(字母/数字组成的两段) */
13
14
  export const SN_PATTERN = /^cnb-[a-z0-9]+-[a-z0-9]+$/i;
@@ -26,7 +27,7 @@ export function invalidSnMessage(sn: string): string {
26
27
  }
27
28
 
28
29
  export async function apiRequest(url: string, headers: Record<string, string>): Promise<any> {
29
- const res = await fetch(url, { headers });
30
+ const res = await wrapFetchError(fetch)(url, { headers });
30
31
  if (!res.ok) {
31
32
  // 注意:错误消息中暴露 URL 是有意的,便于调试。
32
33
  // 本项目所有 API 凭证通过 Authorization header 传递,不走 query string,
@@ -37,7 +38,7 @@ export async function apiRequest(url: string, headers: Record<string, string>):
37
38
  }
38
39
 
39
40
  export async function apiRequestText(url: string, headers: Record<string, string>): Promise<string> {
40
- const res = await fetch(url, { headers });
41
+ const res = await wrapFetchError(fetch)(url, { headers });
41
42
  if (!res.ok) {
42
43
  // 见 apiRequest 注释
43
44
  throw new Error(`请求失败: ${res.status} ${res.statusText} — ${url}`);
@@ -10,6 +10,7 @@ import {
10
10
  } from '../utils/device-auth';
11
11
  import { tryOpenBrowser } from '../utils/open-browser';
12
12
  import { resolvePlatformUrl } from '../utils/resolve-platform-host';
13
+ import { isNetworkError, printFetchError } from '../utils/fetch-error';
13
14
 
14
15
  export function registerLoginCommand(program: Command): void {
15
16
  program
@@ -98,7 +99,14 @@ export function registerLoginCommand(program: Command): void {
98
99
  console.log(`Token 已保存到 ${getTokenPath()}`);
99
100
  }
100
101
  } catch (err: any) {
101
- console.error(`\n登录失败: ${err.message}`);
102
+ console.error('');
103
+ if (isNetworkError(err)) {
104
+ // 网络层失败(DNS / 连接 / 证书 / 超时等),原样输出底层错误信息,不做翻译
105
+ printFetchError(err, '登录失败');
106
+ console.error('提示:若网络需要代理,请先设置 HTTPS_PROXY 后重试;也可用 --debug 查看请求详情。');
107
+ } else {
108
+ console.error(`登录失败: ${err?.message || err}`);
109
+ }
102
110
  process.exit(1);
103
111
  }
104
112
  });
@@ -1,4 +1,5 @@
1
1
  import type { LoginConfig, TokenResponse, TokenErrorResponse } from './types';
2
+ import { wrapFetchError } from './wrap-fetch-error';
2
3
 
3
4
  const GRANT_TYPE_DEVICE_CODE = 'urn:ietf:params:oauth:grant-type:device_code';
4
5
 
@@ -24,7 +25,7 @@ export async function doTokenRequest(
24
25
  console.log(`[DEBUG] POST ${tokenURL} body=${form.toString()}`);
25
26
  }
26
27
 
27
- const resp = await fetch(tokenURL, {
28
+ const resp = await wrapFetchError(fetch)(tokenURL, {
28
29
  method: 'POST',
29
30
  headers,
30
31
  body: form.toString(),
@@ -0,0 +1,71 @@
1
+ import type { FetchErrorLike } from './wrap-fetch-error';
2
+
3
+ /**
4
+ * fetch 抛出的 `TypeError: fetch failed` 本身没有信息量,
5
+ * 真正的原因(ENOTFOUND / ECONNREFUSED / 证书 / 超时…)藏在 err.cause 里。
6
+ *
7
+ * 这里只做「把底层原始信息摊平」,不做翻译:错误文案由 Node / undici 提供,
8
+ * 随版本更新自动同步,也不会因为自造措辞与真实原因对不上而误导排查。
9
+ */
10
+
11
+ /** 错误对象的宽松视图(Node 的 err.cause / err.code 不在标准 Error 类型里) */
12
+ type AnyError = FetchErrorLike & {
13
+ code?: string;
14
+ cause?: any;
15
+ };
16
+
17
+ /** 从 aggregate error(Node 双栈连接失败时常见)里挑一个最能说明问题的子错误 */
18
+ function pickCauseError(cause: any): any {
19
+ if (!cause) return undefined;
20
+ if (Array.isArray(cause.errors) && cause.errors.length > 0) {
21
+ // 优先选非 ECONNREFUSED 的错误:双栈下 IPv4 被拒、IPv6 缺路由,后者更贴近真实原因
22
+ const meaningful = cause.errors.find((e: any) => e && e.code && e.code !== 'ECONNREFUSED');
23
+ return meaningful || cause.errors[0];
24
+ }
25
+ return cause;
26
+ }
27
+
28
+ /**
29
+ * 判断是否为网络层失败(fetch 抛出的 TypeError / 超时 / 被中断)。
30
+ * HTTP 4xx/5xx 不会走到这里——那是响应,不是异常。
31
+ */
32
+ export function isNetworkError(err: any): boolean {
33
+ if (!err) return false;
34
+ return err.name === 'TypeError' || err.name === 'AbortError' || err.name === 'TimeoutError' || !!err.cause;
35
+ }
36
+
37
+ /**
38
+ * 生成错误描述:优先用底层原始信息,并附上错误码。
39
+ * HTTP 状态码层面的错误(4xx/5xx)不走这里,由调用方自行拼装。
40
+ *
41
+ * @param err 捕获到的错误
42
+ * @returns 形如 `getaddrinfo ENOTFOUND abc.invalid.cool(ENOTFOUND)`
43
+ */
44
+ export function describeFetchError(err: AnyError | undefined): string {
45
+ // new Error('') 的 message 为空串但不是 falsy,需显式判空
46
+ const rawMessage = err?.message || (err ? String(err) : '');
47
+ const cause = pickCauseError(err?.cause);
48
+ const code = cause?.code || err?.code;
49
+ const causeMessage = cause?.message || '';
50
+ // `fetch failed` 是 fetch 的兜底文案,有错误码时只给码和底层信息,免得输出「fetch failed(ENOTFOUND)」
51
+ const hasDetail = !!causeMessage || !!code;
52
+ const usableRaw = rawMessage && rawMessage !== 'Error' && !(hasDetail && rawMessage === 'fetch failed') ? rawMessage : '';
53
+ const reason = causeMessage || usableRaw || code || '未知错误';
54
+ // 底层 message 有时已含错误码(如 getaddrinfo ENOTFOUND x),避免重复追加
55
+ return code && !reason.includes(code) ? `${reason}(${code})` : reason;
56
+ }
57
+
58
+ /**
59
+ * 直接打印网络错误提示到 stderr。
60
+ * 保留 `错误:` / `Error:` 双语的仓库惯例,两行都输出原始信息。
61
+ *
62
+ * @param err 捕获到的错误
63
+ * @param context 可选的上下文描述,如「请求 API 失败」「获取 device code 失败」
64
+ */
65
+ export function printFetchError(err: AnyError | undefined, context?: string): void {
66
+ const head = context ? `${context}:` : '';
67
+ console.error(`错误:${head}${describeFetchError(err)}`);
68
+ console.error(`Error: ${head}${err?.message || err}`);
69
+ const url = err?.requestURL;
70
+ if (url) console.error(`请求地址: ${url}`);
71
+ }
@@ -1,5 +1,6 @@
1
1
  import { saveToken } from './save-token';
2
2
  import type { TokenStore, TokenResponse } from './types';
3
+ import { wrapFetchError } from './wrap-fetch-error';
3
4
 
4
5
  /**
5
6
  * 使用 refresh_token 刷新 access_token。
@@ -20,7 +21,7 @@ export async function refreshAccessToken(store: TokenStore): Promise<string> {
20
21
  form.set('client_id', clientID);
21
22
 
22
23
  const nowSec = Math.floor(Date.now() / 1000);
23
- const resp = await fetch(tokenURL, {
24
+ const resp = await wrapFetchError(fetch)(tokenURL, {
24
25
  method: 'POST',
25
26
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
26
27
  body: form.toString(),
@@ -1,4 +1,5 @@
1
1
  import type { LoginConfig, DeviceAuthResponse } from './types';
2
+ import { wrapFetchError } from './wrap-fetch-error';
2
3
 
3
4
  /**
4
5
  * 调用 /oauth2/device/auth 端点获取 device_code / user_code 等。
@@ -17,7 +18,7 @@ export async function requestDeviceCode(cfg: LoginConfig, signal?: AbortSignal):
17
18
  console.log(`[DEBUG] POST ${endpoint} body=${form.toString()}`);
18
19
  }
19
20
 
20
- const resp = await fetch(endpoint, {
21
+ const resp = await wrapFetchError(fetch)(endpoint, {
21
22
  method: 'POST',
22
23
  headers,
23
24
  body: form.toString(),
@@ -9,6 +9,7 @@
9
9
  import fs from 'fs';
10
10
  import nodePath from 'path';
11
11
  import type { ResolvedShortcut } from '../shortcuts';
12
+ import { wrapFetchError } from './wrap-fetch-error';
12
13
 
13
14
  /** 常见文件扩展名 → MIME 类型映射(仅用于上传场景,不引入外部依赖) */
14
15
  const MIME_MAP: Record<string, string> = {
@@ -199,7 +200,7 @@ async function putFileStream(
199
200
  const fileStream = fs.createReadStream(filePath);
200
201
  let putResponse: Response;
201
202
  try {
202
- putResponse = await fetch(uploadUrl, {
203
+ putResponse = await wrapFetchError(fetch)(uploadUrl, {
203
204
  method: 'PUT',
204
205
  headers: { 'Content-Type': contentType, 'Content-Length': String(fileSize) },
205
206
  body: fileStream as any,
@@ -0,0 +1,42 @@
1
+ /**
2
+ * fetch 失败时抛出的 `TypeError: fetch failed` 丢掉了真正的原因,
3
+ * 这里统一补上请求地址(err.requestURL),供上层拼装可读的错误提示。
4
+ */
5
+
6
+ /** 带请求地址的 fetch 错误 */
7
+ export interface FetchErrorLike extends Error {
8
+ requestURL?: string;
9
+ }
10
+
11
+ /**
12
+ * 给 fetch 错误挂上请求地址,方便上层说明「哪个地址失败了」。
13
+ * 不修改原始 message,避免影响既有日志与测试断言。
14
+ */
15
+ export function createFetchError(err: unknown, url?: string): FetchErrorLike {
16
+ const error: FetchErrorLike = err instanceof Error ? err : new Error(String(err));
17
+ if (url && !error.requestURL) {
18
+ try {
19
+ error.requestURL = url;
20
+ } catch {
21
+ // 只读属性等异常场景直接忽略,不影响主流程
22
+ }
23
+ }
24
+ return error;
25
+ }
26
+
27
+ /**
28
+ * 包装 fetch 函数:仅在请求抛错(网络层失败)时附加 requestURL。
29
+ * 用法:`const wrappedFetch = wrapFetchError(fetch); await wrappedFetch(url, init)`
30
+ */
31
+ export function wrapFetchError(
32
+ fetchImpl: typeof fetch,
33
+ ): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
34
+ return async function wrappedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
35
+ const url = typeof input === 'string' ? input : (input as any)?.url || String(input);
36
+ try {
37
+ return await fetchImpl(input, init);
38
+ } catch (err) {
39
+ throw createFetchError(err, url);
40
+ }
41
+ };
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cnbcool/cnb-api-generate",
3
- "version": "2.21.3",
3
+ "version": "2.21.4",
4
4
  "main": "./built/index.js",
5
5
  "module": "./src/index.ts",
6
6
  "types": "./src/index.ts",