zentao-api 0.6.5 → 0.6.7
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/README.md +29 -2
- package/dist/browser/zentao-api.global.js +26 -18
- package/dist/client/index.d.ts +30 -3
- package/dist/client/index.js +303 -27
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/misc/environment.d.ts +0 -2
- package/dist/misc/environment.js +43 -19
- package/dist/misc/errors.d.ts +6 -0
- package/dist/misc/errors.js +9 -0
- package/dist/misc/zentao-version.d.ts +11 -0
- package/dist/misc/zentao-version.js +35 -0
- package/dist/modules/define.d.ts +4 -3
- package/dist/modules/define.js +4 -3
- package/dist/modules/generated.d.ts +2240 -59
- package/dist/modules/generated.js +1230 -50
- package/dist/modules/override.d.ts +2 -0
- package/dist/modules/override.js +39 -66
- package/dist/modules/query.d.ts +13 -10
- package/dist/modules/query.js +30 -26
- package/dist/modules/registry-store.js +3 -0
- package/dist/modules/registry.d.ts +0 -9702
- package/dist/modules/registry.js +0 -4
- package/dist/modules/resolve.js +1 -1
- package/dist/profiles/index.d.ts +3 -1
- package/dist/profiles/index.js +13 -4
- package/dist/request/index.d.ts +15 -4
- package/dist/request/index.js +39 -7
- package/dist/types/client.d.ts +8 -3
- package/dist/types/module.d.ts +13 -2
- package/dist/types/options.d.ts +9 -0
- package/dist/types/profile.d.ts +2 -0
- package/dist/types/response.d.ts +9 -0
- package/dist/utils/doc-helper/index.d.ts +1 -2
- package/dist/utils/doc-helper/index.js +0 -1
- package/dist/utils/doc-helper/inline.d.ts +0 -1
- package/dist/utils/doc-helper/inline.js +3 -81
- package/dist/utils/doc-helper/shared.d.ts +0 -5
- package/dist/utils/doc-helper/shared.js +0 -34
- package/dist/utils/doc-helper/types.d.ts +1 -13
- package/dist/version.js +2 -2
- package/package.json +9 -8
- package/dist/utils/doc-helper/html.d.ts +0 -3
- package/dist/utils/doc-helper/html.js +0 -442
package/dist/client/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ClientRequestOptions, ZentaoClientOptions } from '../types/index.js';
|
|
1
|
+
import type { ClientRequestOptions, GetZentaoConfigOptions, ServerConfig, ZentaoClientOptions } from '../types/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* 禅道 API 客户端,封装一次次原始 HTTP 调用。
|
|
4
4
|
*
|
|
@@ -20,6 +20,10 @@ export declare class ZentaoClient {
|
|
|
20
20
|
private token?;
|
|
21
21
|
private readonly timeout?;
|
|
22
22
|
private readonly insecure?;
|
|
23
|
+
private profileKey?;
|
|
24
|
+
private serverConfig?;
|
|
25
|
+
private serverConfigFetchedAt?;
|
|
26
|
+
private configRequest?;
|
|
23
27
|
/**
|
|
24
28
|
* 使用完整配置创建客户端。
|
|
25
29
|
*
|
|
@@ -42,6 +46,7 @@ export declare class ZentaoClient {
|
|
|
42
46
|
*
|
|
43
47
|
* 特殊处理:
|
|
44
48
|
* - 默认 HTTP 方法为 `GET`,`GET` 请求即使提供了 `options.body` 也不会发送,避免被部分代理/浏览器拒绝。
|
|
49
|
+
* - 不自动跟随重定向,避免把 `Token` 或请求体转发到另一个地址。
|
|
45
50
|
* - 非空响应优先按 JSON 解析;解析失败时回落为字符串原文。
|
|
46
51
|
* - 业务层失败(即响应体 `{ status: "fail" }`)不会抛出,仍按原样返回;只有 HTTP/网络/超时等传输层错误才会抛错。
|
|
47
52
|
* - `insecure` 仅在 Node.js 下可用,浏览器中传入会抛 `E_INSECURE_BROWSER`。
|
|
@@ -50,7 +55,9 @@ export declare class ZentaoClient {
|
|
|
50
55
|
* @param options - 单次请求选项,参见 {@link ClientRequestOptions}。
|
|
51
56
|
* @returns 解析后的响应体;当响应为空字符串时返回 `undefined`。
|
|
52
57
|
* @throws {ZentaoError} 可能抛出 `E_HTTP_ERROR`(非 2xx 状态)、`E_NETWORK_ERROR`(底层 fetch 失败)、
|
|
53
|
-
* `E_TIMEOUT`(超过 `timeout
|
|
58
|
+
* `E_TIMEOUT`(超过 `timeout`)、`E_ABORTED`(外部信号取消)或
|
|
59
|
+
* `E_INSECURE_BROWSER`(浏览器中开启了 `insecure`);传入 `ReadableStream` 请求体时会抛
|
|
60
|
+
* `E_INVALID_PARAM`。
|
|
54
61
|
*/
|
|
55
62
|
request(path: string, options: ClientRequestOptions & {
|
|
56
63
|
responseType: 'response';
|
|
@@ -62,6 +69,24 @@ export declare class ZentaoClient {
|
|
|
62
69
|
responseType: 'blob';
|
|
63
70
|
}): Promise<Blob>;
|
|
64
71
|
request<T = unknown>(path: string, options?: ClientRequestOptions): Promise<T>;
|
|
72
|
+
/** API 与站点配置共用的传输层;配置请求不传入 Token。 */
|
|
73
|
+
private fetchUrl;
|
|
74
|
+
/**
|
|
75
|
+
* 获取禅道站点 `/?mode=getconfig` 配置,不发送 API Token。
|
|
76
|
+
*
|
|
77
|
+
* 默认复用不超过 24 小时的缓存;缺失、过期或时间异常时重新获取。
|
|
78
|
+
* `forceRefresh: true` 忽略缓存。同一客户端的并发刷新共用首次调用的传输选项;
|
|
79
|
+
* 后加入的调用仍可通过自己的 signal 取消等待。
|
|
80
|
+
* 成功后更新实例缓存;启用 `persistProfiles` 且绑定了 profile 时仅更新其配置和获取时间。
|
|
81
|
+
* 返回独立副本,修改返回值不会改变缓存。此方法不会忽略配置获取错误。
|
|
82
|
+
*
|
|
83
|
+
* @param options - 缓存、超时、TLS 与取消选项。
|
|
84
|
+
* @returns 服务器配置。
|
|
85
|
+
* @throws {ZentaoError} 传输错误、`E_INVALID_ZENTAO_CONFIG`、`E_INVALID_ZENTAO_VERSION` 或 profile 存储错误。
|
|
86
|
+
*/
|
|
87
|
+
getZentaoConfig(options?: GetZentaoConfigOptions): Promise<ServerConfig>;
|
|
88
|
+
private loadZentaoConfig;
|
|
89
|
+
private waitForConfig;
|
|
65
90
|
/**
|
|
66
91
|
* 发起 `GET` 请求。
|
|
67
92
|
*
|
|
@@ -103,7 +128,9 @@ export declare class ZentaoClient {
|
|
|
103
128
|
/**
|
|
104
129
|
* 使用账号密码登录禅道。
|
|
105
130
|
*
|
|
106
|
-
*
|
|
131
|
+
* 验证成功后强制获取一次站点配置,再把返回的 Token 写入当前客户端实例;
|
|
132
|
+
* 配置获取失败默认阻断登录,只有全局 `skipVersionCheckOnConfigError` 可允许继续。
|
|
133
|
+
* 全局 `version` 不跳过登录时的配置获取。
|
|
107
134
|
* 当全局 `persistProfiles` 为真时,会同时把账号、Token、用户信息、服务端配置和
|
|
108
135
|
* 客户端偏好(仅在显式设置过 `timeout` / `insecure` 时)持久化为本地 profile,
|
|
109
136
|
* 并切换为当前 profile,方便下次通过 {@link ZentaoClient.fromProfile} 直接登录态恢复。
|
package/dist/client/index.js
CHANGED
|
@@ -1,17 +1,35 @@
|
|
|
1
|
-
import { ZentaoError } from '../misc/errors.js';
|
|
1
|
+
import { isZentaoConfigFetchError, ZentaoError } from '../misc/errors.js';
|
|
2
|
+
import { parseZentaoVersion } from '../misc/zentao-version.js';
|
|
2
3
|
import { assertInsecureSupported, fetchWithInsecureTls } from '../misc/environment.js';
|
|
3
4
|
import { getGlobalOptions, setGlobalOptions } from '../misc/global-options.js';
|
|
4
|
-
import { addProfile, switchProfile } from '../profiles/index.js';
|
|
5
|
+
import { addProfile, switchProfile, updateProfileServerConfig } from '../profiles/index.js';
|
|
5
6
|
import { isRecord, normalizeSiteUrl } from '../utils/index.js';
|
|
6
7
|
const DEFAULT_TIMEOUT = 10000;
|
|
7
|
-
|
|
8
|
+
const CONFIG_MAX_AGE = 24 * 60 * 60 * 1000;
|
|
9
|
+
function isServerConfig(value) {
|
|
10
|
+
return isRecord(value) && typeof value.version === 'string' && value.version.trim().length > 0;
|
|
11
|
+
}
|
|
12
|
+
function appendQueryValue(search, key, value) {
|
|
13
|
+
if (value === undefined)
|
|
14
|
+
return;
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
value.forEach((item, index) => appendQueryValue(search, `${key}[${index}]`, item));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (isPlainObject(value)) {
|
|
20
|
+
for (const [childKey, childValue] of Object.entries(value)) {
|
|
21
|
+
appendQueryValue(search, `${key}[${childKey}]`, childValue);
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
search.append(key, value === null ? '' : String(value));
|
|
26
|
+
}
|
|
27
|
+
/** 拼接 API 路径与查询参数;结构化值使用 deepObject 风格的方括号键。 */
|
|
8
28
|
function buildUrl(baseUrl, path, query) {
|
|
9
29
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
10
30
|
const url = new URL(`${baseUrl}${normalizedPath}`);
|
|
11
31
|
for (const [key, value] of Object.entries(query ?? {})) {
|
|
12
|
-
|
|
13
|
-
continue;
|
|
14
|
-
url.searchParams.set(key, String(value));
|
|
32
|
+
appendQueryValue(url.searchParams, key, value);
|
|
15
33
|
}
|
|
16
34
|
return url.toString();
|
|
17
35
|
}
|
|
@@ -41,6 +59,9 @@ function appendFormValue(form, key, value) {
|
|
|
41
59
|
function serializeBody(body, bodyType, headers) {
|
|
42
60
|
if (body === undefined || body === null)
|
|
43
61
|
return undefined;
|
|
62
|
+
if (isInstanceOfGlobal(body, 'ReadableStream')) {
|
|
63
|
+
throw new ZentaoError('E_INVALID_PARAM', { param: 'body', value: 'ReadableStream' });
|
|
64
|
+
}
|
|
44
65
|
if (bodyType === 'form') {
|
|
45
66
|
const form = body instanceof URLSearchParams ? body : new URLSearchParams();
|
|
46
67
|
if (!(body instanceof URLSearchParams) && isPlainObject(body)) {
|
|
@@ -60,8 +81,7 @@ function serializeBody(body, bodyType, headers) {
|
|
|
60
81
|
isArrayBufferBody(body) ||
|
|
61
82
|
isInstanceOfGlobal(body, 'FormData') ||
|
|
62
83
|
isInstanceOfGlobal(body, 'URLSearchParams') ||
|
|
63
|
-
isInstanceOfGlobal(body, 'Blob')
|
|
64
|
-
isInstanceOfGlobal(body, 'ReadableStream')) {
|
|
84
|
+
isInstanceOfGlobal(body, 'Blob')) {
|
|
65
85
|
return body;
|
|
66
86
|
}
|
|
67
87
|
if (!headers.has('Content-Type')) {
|
|
@@ -71,8 +91,26 @@ function serializeBody(body, bodyType, headers) {
|
|
|
71
91
|
}
|
|
72
92
|
function createRequestSignal(timeout, externalSignal) {
|
|
73
93
|
const controller = new AbortController();
|
|
74
|
-
|
|
75
|
-
|
|
94
|
+
let timedOut = false;
|
|
95
|
+
let cleaned = false;
|
|
96
|
+
let timer;
|
|
97
|
+
const cleanup = () => {
|
|
98
|
+
if (cleaned)
|
|
99
|
+
return;
|
|
100
|
+
cleaned = true;
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
externalSignal?.removeEventListener('abort', abortFromExternal);
|
|
103
|
+
};
|
|
104
|
+
const abortFromExternal = () => {
|
|
105
|
+
controller.abort(externalSignal?.reason);
|
|
106
|
+
cleanup();
|
|
107
|
+
};
|
|
108
|
+
timer = setTimeout(() => {
|
|
109
|
+
timedOut = true;
|
|
110
|
+
controller.abort();
|
|
111
|
+
cleanup();
|
|
112
|
+
}, timeout);
|
|
113
|
+
timer.unref?.();
|
|
76
114
|
if (externalSignal?.aborted) {
|
|
77
115
|
abortFromExternal();
|
|
78
116
|
}
|
|
@@ -81,12 +119,145 @@ function createRequestSignal(timeout, externalSignal) {
|
|
|
81
119
|
}
|
|
82
120
|
return {
|
|
83
121
|
signal: controller.signal,
|
|
84
|
-
cleanup
|
|
85
|
-
|
|
86
|
-
externalSignal?.removeEventListener('abort', abortFromExternal);
|
|
87
|
-
},
|
|
122
|
+
cleanup,
|
|
123
|
+
didTimeout: () => timedOut,
|
|
88
124
|
};
|
|
89
125
|
}
|
|
126
|
+
function keepResponseSignalUntilBodyEnds(response, cleanup, state = { branches: new Set(), cleanup, complete: false }) {
|
|
127
|
+
const branch = Symbol();
|
|
128
|
+
state.branches.add(branch);
|
|
129
|
+
let branchFinished = false;
|
|
130
|
+
const finishBranch = (bodyComplete) => {
|
|
131
|
+
if (branchFinished)
|
|
132
|
+
return;
|
|
133
|
+
branchFinished = true;
|
|
134
|
+
state.branches.delete(branch);
|
|
135
|
+
if (!state.complete && (bodyComplete || state.branches.size === 0)) {
|
|
136
|
+
state.complete = true;
|
|
137
|
+
state.cleanup();
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
let prototype = response;
|
|
141
|
+
let nativeBodyGetter;
|
|
142
|
+
while (prototype && !nativeBodyGetter) {
|
|
143
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'body');
|
|
144
|
+
if (descriptor?.get) {
|
|
145
|
+
nativeBodyGetter = descriptor.get.bind(response);
|
|
146
|
+
}
|
|
147
|
+
prototype = Object.getPrototypeOf(prototype);
|
|
148
|
+
}
|
|
149
|
+
if (!nativeBodyGetter) {
|
|
150
|
+
finishBranch(true);
|
|
151
|
+
return response;
|
|
152
|
+
}
|
|
153
|
+
if (!nativeBodyGetter()) {
|
|
154
|
+
finishBranch(true);
|
|
155
|
+
return response;
|
|
156
|
+
}
|
|
157
|
+
let wrappedBody;
|
|
158
|
+
Object.defineProperty(response, 'body', {
|
|
159
|
+
configurable: true,
|
|
160
|
+
get() {
|
|
161
|
+
const nativeBody = nativeBodyGetter();
|
|
162
|
+
if (!nativeBody) {
|
|
163
|
+
wrappedBody = null;
|
|
164
|
+
finishBranch(true);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (wrappedBody === undefined) {
|
|
168
|
+
let reader;
|
|
169
|
+
const getReader = () => reader ??= nativeBodyGetter().getReader();
|
|
170
|
+
wrappedBody = new ReadableStream({
|
|
171
|
+
type: 'bytes',
|
|
172
|
+
async pull(controller) {
|
|
173
|
+
let activeReader;
|
|
174
|
+
try {
|
|
175
|
+
activeReader = getReader();
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
controller.error(error);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const chunk = await activeReader.read();
|
|
183
|
+
if (chunk.done) {
|
|
184
|
+
finishBranch(true);
|
|
185
|
+
const byobRequest = 'byobRequest' in controller ? controller.byobRequest : null;
|
|
186
|
+
controller.close();
|
|
187
|
+
byobRequest?.respond(0);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
const value = new Uint8Array(chunk.value.byteLength);
|
|
191
|
+
value.set(chunk.value);
|
|
192
|
+
controller.enqueue(value);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
finishBranch(true);
|
|
197
|
+
controller.error(error);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
async cancel(reason) {
|
|
201
|
+
const activeReader = getReader();
|
|
202
|
+
try {
|
|
203
|
+
await activeReader.cancel(reason);
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
finishBranch(false);
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
}, { highWaterMark: 0 });
|
|
210
|
+
}
|
|
211
|
+
return wrappedBody;
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
for (const name of ['arrayBuffer', 'blob', 'formData', 'json', 'text', 'bytes']) {
|
|
215
|
+
const original = Reflect.get(response, name);
|
|
216
|
+
if (typeof original !== 'function')
|
|
217
|
+
continue;
|
|
218
|
+
Object.defineProperty(response, name, {
|
|
219
|
+
configurable: true,
|
|
220
|
+
value: (...args) => {
|
|
221
|
+
if (wrappedBody?.locked) {
|
|
222
|
+
return Promise.reject(new TypeError('Response body is locked.'));
|
|
223
|
+
}
|
|
224
|
+
const nativeBody = nativeBodyGetter();
|
|
225
|
+
const canConsume = !response.bodyUsed && !nativeBody?.locked;
|
|
226
|
+
const wrapperReader = canConsume ? response.body?.getReader() : undefined;
|
|
227
|
+
let result;
|
|
228
|
+
try {
|
|
229
|
+
result = Reflect.apply(original, response, args);
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
wrapperReader?.releaseLock();
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
if (!canConsume)
|
|
236
|
+
return result;
|
|
237
|
+
return result.then((value) => {
|
|
238
|
+
wrapperReader?.releaseLock();
|
|
239
|
+
finishBranch(true);
|
|
240
|
+
return value;
|
|
241
|
+
}, (error) => {
|
|
242
|
+
wrapperReader?.releaseLock();
|
|
243
|
+
if (response.bodyUsed)
|
|
244
|
+
finishBranch(true);
|
|
245
|
+
throw error;
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const clone = response.clone.bind(response);
|
|
251
|
+
Object.defineProperty(response, 'clone', {
|
|
252
|
+
configurable: true,
|
|
253
|
+
value: () => {
|
|
254
|
+
if (wrappedBody?.locked)
|
|
255
|
+
throw new TypeError('Response body is locked.');
|
|
256
|
+
return keepResponseSignalUntilBodyEnds(clone(), cleanup, state);
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
return response;
|
|
260
|
+
}
|
|
90
261
|
/** 按指定策略解析响应;默认优先 JSON,失败后保留原始文本。 */
|
|
91
262
|
async function parseResponse(response, responseType = 'auto') {
|
|
92
263
|
if (responseType === 'response')
|
|
@@ -132,6 +303,10 @@ export class ZentaoClient {
|
|
|
132
303
|
token;
|
|
133
304
|
timeout;
|
|
134
305
|
insecure;
|
|
306
|
+
profileKey;
|
|
307
|
+
serverConfig;
|
|
308
|
+
serverConfigFetchedAt;
|
|
309
|
+
configRequest;
|
|
135
310
|
constructor(input) {
|
|
136
311
|
const options = typeof input === 'string' ? { baseUrl: input } : input;
|
|
137
312
|
this.siteUrl = normalizeSiteUrl(options.baseUrl);
|
|
@@ -141,26 +316,33 @@ export class ZentaoClient {
|
|
|
141
316
|
this.insecure = options.insecure;
|
|
142
317
|
}
|
|
143
318
|
async request(path, options = {}) {
|
|
319
|
+
return this.fetchUrl(buildUrl(this.baseUrl, path, options.query), options, this.token);
|
|
320
|
+
}
|
|
321
|
+
/** API 与站点配置共用的传输层;配置请求不传入 Token。 */
|
|
322
|
+
async fetchUrl(url, options, token, cache) {
|
|
144
323
|
const globals = getGlobalOptions();
|
|
145
324
|
const method = options.method ?? 'GET';
|
|
146
325
|
const timeout = options.timeout ?? globals.timeout ?? this.timeout ?? DEFAULT_TIMEOUT;
|
|
147
326
|
const insecure = options.insecure ?? globals.insecure ?? this.insecure;
|
|
148
327
|
assertInsecureSupported(insecure);
|
|
149
|
-
const url = buildUrl(this.baseUrl, path, options.query);
|
|
150
328
|
const headers = new Headers(options.headers);
|
|
151
|
-
if (
|
|
152
|
-
headers.set('Token',
|
|
329
|
+
if (token) {
|
|
330
|
+
headers.set('Token', token);
|
|
153
331
|
}
|
|
154
|
-
const { signal, cleanup } = createRequestSignal(timeout, options.signal);
|
|
155
332
|
const init = {
|
|
156
333
|
method,
|
|
157
334
|
headers,
|
|
158
|
-
|
|
335
|
+
redirect: 'manual',
|
|
336
|
+
cache,
|
|
159
337
|
};
|
|
160
338
|
// GET 请求不携带 body,避免浏览器和部分代理拒绝请求。
|
|
161
339
|
if (options.body !== undefined && method !== 'GET') {
|
|
162
340
|
init.body = serializeBody(options.body, options.bodyType, headers);
|
|
163
341
|
}
|
|
342
|
+
// 先完成同步序列化,再启动计时器,避免序列化失败遗留活动 timer。
|
|
343
|
+
const { signal, cleanup, didTimeout } = createRequestSignal(timeout, options.signal);
|
|
344
|
+
init.signal = signal;
|
|
345
|
+
let keepResponseSignal = false;
|
|
164
346
|
try {
|
|
165
347
|
const response = await fetchWithInsecureTls(insecure, url, init);
|
|
166
348
|
if (!response.ok) {
|
|
@@ -174,18 +356,93 @@ export class ZentaoClient {
|
|
|
174
356
|
body: await response.text().catch(() => undefined),
|
|
175
357
|
});
|
|
176
358
|
}
|
|
177
|
-
|
|
359
|
+
if (options.responseType === 'response') {
|
|
360
|
+
keepResponseSignal = true;
|
|
361
|
+
return keepResponseSignalUntilBodyEnds(response, cleanup);
|
|
362
|
+
}
|
|
363
|
+
return await parseResponse(response, options.responseType);
|
|
178
364
|
}
|
|
179
365
|
catch (error) {
|
|
366
|
+
if (signal.aborted) {
|
|
367
|
+
throw new ZentaoError(didTimeout() ? 'E_TIMEOUT' : 'E_ABORTED', undefined, error);
|
|
368
|
+
}
|
|
180
369
|
if (error instanceof ZentaoError)
|
|
181
370
|
throw error;
|
|
182
371
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
183
|
-
throw new ZentaoError('
|
|
372
|
+
throw new ZentaoError('E_ABORTED', undefined, error);
|
|
184
373
|
}
|
|
185
374
|
throw new ZentaoError('E_NETWORK_ERROR', { message: error.message ?? String(error) }, error);
|
|
186
375
|
}
|
|
187
376
|
finally {
|
|
188
|
-
|
|
377
|
+
if (!keepResponseSignal)
|
|
378
|
+
cleanup();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* 获取禅道站点 `/?mode=getconfig` 配置,不发送 API Token。
|
|
383
|
+
*
|
|
384
|
+
* 默认复用不超过 24 小时的缓存;缺失、过期或时间异常时重新获取。
|
|
385
|
+
* `forceRefresh: true` 忽略缓存。同一客户端的并发刷新共用首次调用的传输选项;
|
|
386
|
+
* 后加入的调用仍可通过自己的 signal 取消等待。
|
|
387
|
+
* 成功后更新实例缓存;启用 `persistProfiles` 且绑定了 profile 时仅更新其配置和获取时间。
|
|
388
|
+
* 返回独立副本,修改返回值不会改变缓存。此方法不会忽略配置获取错误。
|
|
389
|
+
*
|
|
390
|
+
* @param options - 缓存、超时、TLS 与取消选项。
|
|
391
|
+
* @returns 服务器配置。
|
|
392
|
+
* @throws {ZentaoError} 传输错误、`E_INVALID_ZENTAO_CONFIG`、`E_INVALID_ZENTAO_VERSION` 或 profile 存储错误。
|
|
393
|
+
*/
|
|
394
|
+
async getZentaoConfig(options = {}) {
|
|
395
|
+
const config = await this.loadZentaoConfig(options, getGlobalOptions().persistProfiles ? this.profileKey : undefined);
|
|
396
|
+
return structuredClone(config);
|
|
397
|
+
}
|
|
398
|
+
async loadZentaoConfig(options, profileKey) {
|
|
399
|
+
if (options.signal?.aborted)
|
|
400
|
+
throw new ZentaoError('E_ABORTED');
|
|
401
|
+
// 刷新进行中时等待其结果,避免同一批调用混用旧缓存和新配置。
|
|
402
|
+
if (this.configRequest)
|
|
403
|
+
return this.waitForConfig(this.configRequest, options.signal);
|
|
404
|
+
const fetchedAt = typeof this.serverConfigFetchedAt === 'string' ? Date.parse(this.serverConfigFetchedAt) : NaN;
|
|
405
|
+
const age = Date.now() - fetchedAt;
|
|
406
|
+
if (!options.forceRefresh && isServerConfig(this.serverConfig) && age >= 0 && age <= CONFIG_MAX_AGE) {
|
|
407
|
+
parseZentaoVersion(this.serverConfig.version);
|
|
408
|
+
return this.serverConfig;
|
|
409
|
+
}
|
|
410
|
+
const pending = this.fetchUrl(buildUrl(this.siteUrl, '/', { mode: 'getconfig' }), {
|
|
411
|
+
method: 'GET', timeout: options.timeout, insecure: options.insecure, signal: options.signal,
|
|
412
|
+
}, undefined, 'no-store').then(async (config) => {
|
|
413
|
+
if (!isServerConfig(config))
|
|
414
|
+
throw new ZentaoError('E_INVALID_ZENTAO_CONFIG');
|
|
415
|
+
parseZentaoVersion(config.version);
|
|
416
|
+
const timestamp = new Date().toISOString();
|
|
417
|
+
if (profileKey)
|
|
418
|
+
await updateProfileServerConfig(profileKey, config, timestamp);
|
|
419
|
+
this.serverConfig = config;
|
|
420
|
+
this.serverConfigFetchedAt = timestamp;
|
|
421
|
+
return config;
|
|
422
|
+
});
|
|
423
|
+
this.configRequest = pending;
|
|
424
|
+
try {
|
|
425
|
+
return await pending;
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
this.configRequest = undefined;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
async waitForConfig(pending, signal) {
|
|
432
|
+
if (!signal)
|
|
433
|
+
return pending;
|
|
434
|
+
let onAbort;
|
|
435
|
+
const aborted = new Promise((_, reject) => {
|
|
436
|
+
onAbort = () => reject(new ZentaoError('E_ABORTED'));
|
|
437
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
438
|
+
if (signal.aborted)
|
|
439
|
+
onAbort();
|
|
440
|
+
});
|
|
441
|
+
try {
|
|
442
|
+
return await Promise.race([pending, aborted]);
|
|
443
|
+
}
|
|
444
|
+
finally {
|
|
445
|
+
signal.removeEventListener('abort', onAbort);
|
|
189
446
|
}
|
|
190
447
|
}
|
|
191
448
|
/**
|
|
@@ -237,7 +494,9 @@ export class ZentaoClient {
|
|
|
237
494
|
/**
|
|
238
495
|
* 使用账号密码登录禅道。
|
|
239
496
|
*
|
|
240
|
-
*
|
|
497
|
+
* 验证成功后强制获取一次站点配置,再把返回的 Token 写入当前客户端实例;
|
|
498
|
+
* 配置获取失败默认阻断登录,只有全局 `skipVersionCheckOnConfigError` 可允许继续。
|
|
499
|
+
* 全局 `version` 不跳过登录时的配置获取。
|
|
241
500
|
* 当全局 `persistProfiles` 为真时,会同时把账号、Token、用户信息、服务端配置和
|
|
242
501
|
* 客户端偏好(仅在显式设置过 `timeout` / `insecure` 时)持久化为本地 profile,
|
|
243
502
|
* 并切换为当前 profile,方便下次通过 {@link ZentaoClient.fromProfile} 直接登录态恢复。
|
|
@@ -253,8 +512,17 @@ export class ZentaoClient {
|
|
|
253
512
|
if (response.status !== 'success' || !response.token) {
|
|
254
513
|
throw new ZentaoError('E_LOGIN_FAILED');
|
|
255
514
|
}
|
|
256
|
-
this.token = response.token;
|
|
257
515
|
const globals = getGlobalOptions();
|
|
516
|
+
let serverConfig;
|
|
517
|
+
try {
|
|
518
|
+
// 共用 getZentaoConfig 的获取流程,但登录确认前不能刷新之前绑定的账号。
|
|
519
|
+
serverConfig = await this.loadZentaoConfig({ forceRefresh: true });
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
if (!globals.skipVersionCheckOnConfigError || !isZentaoConfigFetchError(error))
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
525
|
+
let profileKey;
|
|
258
526
|
if (globals.persistProfiles) {
|
|
259
527
|
const config = {};
|
|
260
528
|
const timeout = this.timeout ?? globals.timeout;
|
|
@@ -263,15 +531,19 @@ export class ZentaoClient {
|
|
|
263
531
|
config.timeout = timeout;
|
|
264
532
|
if (insecure !== undefined)
|
|
265
533
|
config.insecure = insecure;
|
|
266
|
-
await addProfile({
|
|
534
|
+
const profile = await addProfile({
|
|
267
535
|
server: this.siteUrl,
|
|
268
536
|
account,
|
|
269
537
|
token: response.token,
|
|
270
538
|
user: isRecord(response.user) ? response.user : undefined,
|
|
271
|
-
serverConfig:
|
|
539
|
+
serverConfig: serverConfig ? structuredClone(serverConfig) : undefined,
|
|
540
|
+
serverConfigFetchedAt: serverConfig ? this.serverConfigFetchedAt : undefined,
|
|
272
541
|
config: Object.keys(config).length > 0 ? config : undefined,
|
|
273
542
|
});
|
|
543
|
+
profileKey = profile.key;
|
|
274
544
|
}
|
|
545
|
+
this.token = response.token;
|
|
546
|
+
this.profileKey = profileKey;
|
|
275
547
|
return response.token;
|
|
276
548
|
}
|
|
277
549
|
/**
|
|
@@ -314,11 +586,15 @@ export class ZentaoClient {
|
|
|
314
586
|
// switchProfile 会在内部读取存储、校验 key 并刷新 lastUsedTime 后写回,
|
|
315
587
|
// 若 key 不存在会抛出 E_PROFILE_NOT_FOUND;不传 key 时由 switchCurrentProfile 处理。
|
|
316
588
|
const activeProfile = await switchProfile(profileKey);
|
|
317
|
-
|
|
589
|
+
const client = new ZentaoClient({
|
|
318
590
|
baseUrl: activeProfile.server,
|
|
319
591
|
token: activeProfile.token,
|
|
320
592
|
timeout: typeof activeProfile.config?.timeout === 'number' ? activeProfile.config.timeout : undefined,
|
|
321
593
|
insecure: typeof activeProfile.config?.insecure === 'boolean' ? activeProfile.config.insecure : undefined,
|
|
322
594
|
});
|
|
595
|
+
client.profileKey = activeProfile.key;
|
|
596
|
+
client.serverConfig = activeProfile.serverConfig;
|
|
597
|
+
client.serverConfigFetchedAt = activeProfile.serverConfigFetchedAt;
|
|
598
|
+
return client;
|
|
323
599
|
}
|
|
324
600
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ export { ZentaoClient } from './client/index.js';
|
|
|
2
2
|
export { ERRORS, ZentaoError, type ErrorCode } from './misc/errors.js';
|
|
3
3
|
export { getGlobalOptions, setGlobalOptions } from './misc/global-options.js';
|
|
4
4
|
export { ZENTAO_PROFILES_STORAGE_KEY, addProfile, deleteProfile, getAllProfiles, getProfile, getProfileKey, switchProfile, } from './profiles/index.js';
|
|
5
|
-
export { defineModuleActions, defineModules, type DefineModulesOptions, type ExportRegistryOptions, type ExportedModuleAction, type ExportedModuleDefinition, exportRegistry, extendModuleAction, getModuleNames, getModule, getModuleAction, getModuleActionParams, getObjectProps, } from './modules/registry.js';
|
|
5
|
+
export { defineModuleActions, defineModules, type DefineModulesOptions, type ExportRegistryOptions, type ExportedModuleAction, type ExportedModuleDefinition, exportRegistry, extendModuleAction, getModuleNames, isModuleName, getModule, getModuleAction, getModuleActionParams, getObjectProps, } from './modules/registry.js';
|
|
6
6
|
export { request, type BuiltinRequestName, type RequestParamsFor, type RequestResultFor, } from './request/index.js';
|
|
7
7
|
export { pickFields, pickFieldsSingle, filterData, searchData, sortData, processData, } from './utils/index.js';
|
|
8
8
|
export { BUILD, VERSION } from './version.js';
|
|
9
|
-
export type
|
|
9
|
+
export type * from './types/index.js';
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ export { ZentaoClient } from './client/index.js';
|
|
|
2
2
|
export { ERRORS, ZentaoError } from './misc/errors.js';
|
|
3
3
|
export { getGlobalOptions, setGlobalOptions } from './misc/global-options.js';
|
|
4
4
|
export { ZENTAO_PROFILES_STORAGE_KEY, addProfile, deleteProfile, getAllProfiles, getProfile, getProfileKey, switchProfile, } from './profiles/index.js';
|
|
5
|
-
export { defineModuleActions, defineModules, exportRegistry, extendModuleAction, getModuleNames, getModule, getModuleAction, getModuleActionParams, getObjectProps, } from './modules/registry.js';
|
|
5
|
+
export { defineModuleActions, defineModules, exportRegistry, extendModuleAction, getModuleNames, isModuleName, getModule, getModuleAction, getModuleActionParams, getObjectProps, } from './modules/registry.js';
|
|
6
6
|
export { request, } from './request/index.js';
|
|
7
7
|
export { pickFields, pickFieldsSingle, filterData, searchData, sortData, processData, } from './utils/index.js';
|
|
8
8
|
export { BUILD, VERSION } from './version.js';
|
|
@@ -4,5 +4,3 @@ export declare function isNodeRuntime(): boolean;
|
|
|
4
4
|
export declare function assertInsecureSupported(enabled: boolean | undefined): void;
|
|
5
5
|
/** 发起 fetch 请求;Node.js 下的 `insecure` 只作用于当前 HTTPS 请求。 */
|
|
6
6
|
export declare function fetchWithInsecureTls(enabled: boolean | undefined, url: string, init: RequestInit): Promise<Response>;
|
|
7
|
-
/** 保留给内部测试和兼容调用:校验 TLS 选项,但不再改写进程级环境变量。 */
|
|
8
|
-
export declare function withInsecureTls<T>(enabled: boolean | undefined, fn: () => Promise<T>): Promise<T>;
|
package/dist/misc/environment.js
CHANGED
|
@@ -61,6 +61,9 @@ async function toNodeBody(body, headers) {
|
|
|
61
61
|
}
|
|
62
62
|
return body.toString();
|
|
63
63
|
}
|
|
64
|
+
if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) {
|
|
65
|
+
throw new ZentaoError('E_INVALID_PARAM', { param: 'body', value: 'ReadableStream' });
|
|
66
|
+
}
|
|
64
67
|
return String(body);
|
|
65
68
|
}
|
|
66
69
|
function abortError() {
|
|
@@ -82,23 +85,55 @@ async function nodeFetchWithTlsOptions(url, init, rejectUnauthorized) {
|
|
|
82
85
|
? await importNodeModule('node:https')
|
|
83
86
|
: await importNodeModule('node:http');
|
|
84
87
|
const headers = toNodeRequestHeaders(init.headers);
|
|
88
|
+
if (!hasHeader(headers, 'accept-encoding')) {
|
|
89
|
+
headers['accept-encoding'] = 'identity';
|
|
90
|
+
}
|
|
85
91
|
const body = await toNodeBody(init.body, headers);
|
|
86
92
|
return new Promise((resolve, reject) => {
|
|
87
93
|
if (init.signal?.aborted) {
|
|
88
94
|
reject(abortError());
|
|
89
95
|
return;
|
|
90
96
|
}
|
|
97
|
+
let settled = false;
|
|
98
|
+
let activeResponse;
|
|
99
|
+
const cleanup = () => {
|
|
100
|
+
init.signal?.removeEventListener('abort', abortHandler);
|
|
101
|
+
};
|
|
102
|
+
const rejectOnce = (error) => {
|
|
103
|
+
if (settled)
|
|
104
|
+
return;
|
|
105
|
+
settled = true;
|
|
106
|
+
cleanup();
|
|
107
|
+
reject(error);
|
|
108
|
+
};
|
|
109
|
+
const resolveOnce = (response) => {
|
|
110
|
+
if (settled)
|
|
111
|
+
return;
|
|
112
|
+
settled = true;
|
|
113
|
+
cleanup();
|
|
114
|
+
resolve(response);
|
|
115
|
+
};
|
|
91
116
|
const request = transport.request(parsed, {
|
|
92
117
|
method: init.method ?? 'GET',
|
|
93
118
|
headers,
|
|
94
119
|
rejectUnauthorized,
|
|
95
120
|
}, (response) => {
|
|
121
|
+
activeResponse = response;
|
|
96
122
|
const chunks = [];
|
|
123
|
+
let ended = false;
|
|
97
124
|
response.on('data', (chunk) => {
|
|
98
125
|
chunks.push(typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk);
|
|
99
126
|
});
|
|
100
|
-
response.
|
|
101
|
-
|
|
127
|
+
response.once('aborted', () => {
|
|
128
|
+
rejectOnce(new Error('HTTP response was aborted before completion.'));
|
|
129
|
+
});
|
|
130
|
+
response.once('error', rejectOnce);
|
|
131
|
+
response.once('close', () => {
|
|
132
|
+
if (!ended)
|
|
133
|
+
rejectOnce(new Error('HTTP response closed before completion.'));
|
|
134
|
+
});
|
|
135
|
+
response.once('end', () => {
|
|
136
|
+
ended = true;
|
|
102
137
|
const responseBody = chunks.length > 0 ? concatenateChunks(chunks) : undefined;
|
|
103
138
|
const fetchResponse = new Response(responseBody, {
|
|
104
139
|
status: response.statusCode ?? 200,
|
|
@@ -106,20 +141,16 @@ async function nodeFetchWithTlsOptions(url, init, rejectUnauthorized) {
|
|
|
106
141
|
headers: toResponseHeaders(response.headers),
|
|
107
142
|
});
|
|
108
143
|
Object.defineProperty(fetchResponse, 'url', { value: url });
|
|
109
|
-
|
|
144
|
+
resolveOnce(fetchResponse);
|
|
110
145
|
});
|
|
111
146
|
});
|
|
112
|
-
const cleanup = () => {
|
|
113
|
-
init.signal?.removeEventListener('abort', abortHandler);
|
|
114
|
-
};
|
|
115
147
|
const abortHandler = () => {
|
|
116
|
-
|
|
117
|
-
|
|
148
|
+
const error = abortError();
|
|
149
|
+
rejectOnce(error);
|
|
150
|
+
activeResponse?.destroy(error);
|
|
151
|
+
request.destroy(error);
|
|
118
152
|
};
|
|
119
|
-
request.
|
|
120
|
-
cleanup();
|
|
121
|
-
reject(error);
|
|
122
|
-
});
|
|
153
|
+
request.once('error', rejectOnce);
|
|
123
154
|
init.signal?.addEventListener('abort', abortHandler, { once: true });
|
|
124
155
|
if (body !== undefined)
|
|
125
156
|
request.write(body);
|
|
@@ -139,10 +170,3 @@ export async function fetchWithInsecureTls(enabled, url, init) {
|
|
|
139
170
|
assertInsecureSupported(enabled);
|
|
140
171
|
return nodeFetchWithTlsOptions(url, init, false);
|
|
141
172
|
}
|
|
142
|
-
/** 保留给内部测试和兼容调用:校验 TLS 选项,但不再改写进程级环境变量。 */
|
|
143
|
-
export async function withInsecureTls(enabled, fn) {
|
|
144
|
-
if (!enabled)
|
|
145
|
-
return fn();
|
|
146
|
-
assertInsecureSupported(enabled);
|
|
147
|
-
return fn();
|
|
148
|
-
}
|