@h-ai/api-client 0.1.0-alpha.13 → 0.1.0-alpha.16

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/dist/index.js CHANGED
@@ -1,10 +1,16 @@
1
+ import { IAM_AUTH_ROUTES, apiContract } from '@h-ai/api-contract';
1
2
  import { core, ok, err } from '@h-ai/core';
3
+ import { createORPCClient, ORPCError } from '@orpc/client';
4
+ import { OpenAPILink } from '@orpc/openapi-client/fetch';
2
5
  import { z } from 'zod';
3
6
 
4
- // src/api-client-auth.ts
7
+ // src/api-client-main.ts
8
+ var logger = core.logger.child({ module: "api-client", scope: "auth" });
9
+ var HTTPONLY_COOKIE_SENTINEL = "__httponly_cookie__";
5
10
  var LS_ACCESS_KEY = "hai_access_token";
6
11
  var LS_REFRESH_KEY = "hai_refresh_token";
7
12
  function createLocalStorageTokenStorage() {
13
+ logger.warn("apiClient.tokenStorage.localStorage: storing tokens in localStorage exposes them to XSS. Prefer httpOnly cookies for production.");
8
14
  return {
9
15
  async getAccessToken() {
10
16
  return globalThis.localStorage?.getItem(LS_ACCESS_KEY) ?? null;
@@ -46,445 +52,40 @@ function createMemoryTokenStorage() {
46
52
  }
47
53
  };
48
54
  }
49
-
50
- // messages/en-US.json
51
- var en_US_default = {
52
- apiClient_networkError: "Network request failed",
53
- apiClient_timeout: "Request timeout",
54
- apiClient_serverError: "Server error ({status})",
55
- apiClient_unauthorized: "Unauthorized, please log in again",
56
- apiClient_forbidden: "Access forbidden",
57
- apiClient_notFound: "Resource not found",
58
- apiClient_validationFailed: "Request validation failed",
59
- apiClient_tokenRefreshFailed: "Token refresh failed, please log in again",
60
- apiClient_notInitialized: "API client not initialized, please call api.init() first",
61
- apiClient_configError: "API client config error: {error}",
62
- apiClient_responseValidationFailed: "Response validation failed",
63
- apiClient_unknown: "Unknown error"
64
- };
65
-
66
- // messages/zh-CN.json
67
- var zh_CN_default = {
68
- apiClient_networkError: "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25",
69
- apiClient_timeout: "\u8BF7\u6C42\u8D85\u65F6",
70
- apiClient_serverError: "\u670D\u52A1\u5668\u9519\u8BEF\uFF08{status}\uFF09",
71
- apiClient_unauthorized: "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
72
- apiClient_forbidden: "\u65E0\u6743\u9650\u8BBF\u95EE",
73
- apiClient_notFound: "\u8BF7\u6C42\u7684\u8D44\u6E90\u4E0D\u5B58\u5728",
74
- apiClient_validationFailed: "\u8BF7\u6C42\u53C2\u6570\u6821\u9A8C\u5931\u8D25",
75
- apiClient_tokenRefreshFailed: "Token\u5237\u65B0\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
76
- apiClient_notInitialized: "API \u5BA2\u6237\u7AEF\u672A\u521D\u59CB\u5316\uFF0C\u8BF7\u5148\u8C03\u7528 api.init()",
77
- apiClient_configError: "API \u5BA2\u6237\u7AEF\u914D\u7F6E\u9519\u8BEF\uFF1A{error}",
78
- apiClient_responseValidationFailed: "\u54CD\u5E94\u6570\u636E\u6821\u9A8C\u5931\u8D25",
79
- apiClient_unknown: "\u672A\u77E5\u9519\u8BEF"
80
- };
81
-
82
- // src/api-client-i18n.ts
83
- var apiClientM = core.i18n.createMessageGetter({
84
- "zh-CN": zh_CN_default,
85
- "en-US": en_US_default
86
- });
87
- var ApiClientErrorInfo = {
88
- NETWORK_ERROR: "001:502",
89
- TIMEOUT: "002:504",
90
- SERVER_ERROR: "003:502",
91
- UNAUTHORIZED: "004:401",
92
- FORBIDDEN: "005:403",
93
- NOT_FOUND: "006:404",
94
- VALIDATION_FAILED: "007:400",
95
- TOKEN_REFRESH_FAILED: "008:401",
96
- NOT_INITIALIZED: "010:500",
97
- CONFIG_ERROR: "011:500",
98
- UNKNOWN: "099:500"
99
- };
100
- var HaiApiClientError = core.error.buildHaiErrorsDef("api-client", ApiClientErrorInfo);
101
- function defineEndpoint(def) {
102
- return def;
103
- }
104
-
105
- // src/api-client-contract.ts
106
- function createContractCaller(fetchClient) {
107
- async function call(endpoint, input) {
108
- const parsed = endpoint.input.safeParse(input);
109
- if (!parsed.success) {
110
- return err(
111
- HaiApiClientError.VALIDATION_FAILED,
112
- apiClientM("apiClient_validationFailed"),
113
- parsed.error.issues
114
- );
115
- }
116
- const validInput = parsed.data;
117
- let result;
118
- switch (endpoint.method) {
119
- case "GET":
120
- result = await fetchClient.get(endpoint.path, validInput);
121
- break;
122
- case "POST":
123
- result = await fetchClient.post(endpoint.path, validInput);
124
- break;
125
- case "PUT":
126
- result = await fetchClient.put(endpoint.path, validInput);
127
- break;
128
- case "PATCH":
129
- result = await fetchClient.patch(endpoint.path, validInput);
130
- break;
131
- case "DELETE":
132
- result = await fetchClient.delete(endpoint.path, validInput);
133
- break;
134
- default:
135
- return err(
136
- HaiApiClientError.UNKNOWN,
137
- apiClientM("apiClient_unknown")
138
- );
139
- }
140
- if (!result.success) {
141
- return result;
142
- }
143
- const outputParsed = endpoint.output.safeParse(result.data);
144
- if (!outputParsed.success) {
145
- return err(
146
- HaiApiClientError.VALIDATION_FAILED,
147
- apiClientM("apiClient_responseValidationFailed"),
148
- outputParsed.error.issues
149
- );
150
- }
151
- return ok(outputParsed.data);
152
- }
153
- return call;
154
- }
155
- async function responseToError(response) {
156
- let details;
157
- try {
158
- const body = await response.json();
159
- details = body;
160
- } catch {
161
- }
162
- const status = response.status;
163
- if (status === 401) {
164
- return err(
165
- HaiApiClientError.UNAUTHORIZED,
166
- apiClientM("apiClient_unauthorized"),
167
- details
168
- );
169
- }
170
- if (status === 403) {
171
- return err(
172
- HaiApiClientError.FORBIDDEN,
173
- apiClientM("apiClient_forbidden"),
174
- details
175
- );
176
- }
177
- if (status === 404) {
178
- return err(
179
- HaiApiClientError.NOT_FOUND,
180
- apiClientM("apiClient_notFound"),
181
- details
182
- );
183
- }
184
- if (status === 400 || status === 422) {
185
- return err(
186
- HaiApiClientError.VALIDATION_FAILED,
187
- apiClientM("apiClient_validationFailed"),
188
- details
189
- );
190
- }
191
- if (status >= 500) {
192
- return err(
193
- HaiApiClientError.SERVER_ERROR,
194
- apiClientM("apiClient_serverError", { params: { status: String(status) } }),
195
- details
196
- );
197
- }
198
- return err(
199
- HaiApiClientError.UNKNOWN,
200
- apiClientM("apiClient_unknown"),
201
- details
202
- );
203
- }
204
- function networkErrorToResult(cause) {
205
- if (cause instanceof DOMException && cause.name === "AbortError") {
206
- return err(
207
- HaiApiClientError.TIMEOUT,
208
- apiClientM("apiClient_timeout"),
209
- cause
210
- );
211
- }
212
- return err(
213
- HaiApiClientError.NETWORK_ERROR,
214
- apiClientM("apiClient_networkError"),
215
- cause
216
- );
217
- }
218
-
219
- // src/api-client-fetch.ts
220
- var logger = core.logger.child({ module: "api-client", scope: "fetch" });
221
- var DEFAULT_TIMEOUT = 3e4;
222
- var TRAILING_SLASHES_REGEX = /\/+$/;
223
- function createFetchClient(config, tokenManager) {
224
- const fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
225
- const timeout = config.timeout ?? DEFAULT_TIMEOUT;
226
- const requestInterceptors = config.interceptors?.request ?? [];
227
- const responseInterceptors = config.interceptors?.response ?? [];
228
- function buildUrl(path, params) {
229
- const base = config.baseUrl.replace(TRAILING_SLASHES_REGEX, "");
230
- const normalizedPath = path.startsWith("/") ? path : `/${path}`;
231
- const url = new URL(`${base}${normalizedPath}`);
232
- if (params) {
233
- for (const [key, value] of Object.entries(params)) {
234
- if (value !== void 0 && value !== null) {
235
- url.searchParams.set(key, String(value));
236
- }
237
- }
238
- }
239
- return url.toString();
240
- }
241
- async function execute(requestConfig, retryOnUnauthorized = true) {
242
- logger.trace("Executing request", { method: requestConfig.method, url: requestConfig.url });
243
- if (tokenManager) {
244
- const accessToken = await tokenManager.storage.getAccessToken();
245
- if (accessToken) {
246
- requestConfig.headers.Authorization = `Bearer ${accessToken}`;
247
- }
248
- }
249
- let finalConfig = requestConfig;
250
- for (const interceptor of requestInterceptors) {
251
- finalConfig = await interceptor(finalConfig);
252
- }
253
- const controller = new AbortController();
254
- const timeoutId = setTimeout(() => controller.abort(), timeout);
255
- finalConfig.signal = controller.signal;
256
- try {
257
- let response = await fetchFn(finalConfig.url, {
258
- method: finalConfig.method,
259
- headers: finalConfig.headers,
260
- body: finalConfig.body,
261
- signal: finalConfig.signal
262
- });
263
- clearTimeout(timeoutId);
264
- for (const interceptor of responseInterceptors) {
265
- response = await interceptor(response);
266
- }
267
- if (response.status === 401 && retryOnUnauthorized && tokenManager) {
268
- logger.trace("Received 401, attempting token refresh", { url: requestConfig.url });
269
- const tokens = await tokenManager.refresh();
270
- if (tokens) {
271
- logger.trace("Token refreshed, retrying request", { url: requestConfig.url });
272
- requestConfig.headers.Authorization = `Bearer ${tokens.accessToken}`;
273
- return execute(requestConfig, false);
274
- }
275
- logger.warn("Token refresh failed, returning 401 error", { url: requestConfig.url });
276
- }
277
- if (!response.ok) {
278
- return responseToError(response);
279
- }
280
- const contentType = response.headers.get("content-type") ?? "";
281
- if (contentType.includes("application/json")) {
282
- const body = await response.json();
283
- if (body && typeof body === "object" && "data" in body) {
284
- return ok(body.data);
285
- }
286
- return ok(body);
287
- }
288
- return ok(void 0);
289
- } catch (cause) {
290
- clearTimeout(timeoutId);
291
- logger.warn("Request failed", { url: requestConfig.url, error: cause });
292
- return networkErrorToResult(cause);
293
- }
294
- }
295
- async function get(path, params) {
296
- return execute({
297
- url: buildUrl(path, params),
298
- method: "GET",
299
- headers: { Accept: "application/json" }
300
- });
301
- }
302
- async function post(path, body) {
303
- return execute({
304
- url: buildUrl(path),
305
- method: "POST",
306
- headers: { "Content-Type": "application/json", "Accept": "application/json" },
307
- body: body !== void 0 ? JSON.stringify(body) : void 0
308
- });
309
- }
310
- async function put(path, body) {
311
- return execute({
312
- url: buildUrl(path),
313
- method: "PUT",
314
- headers: { "Content-Type": "application/json", "Accept": "application/json" },
315
- body: body !== void 0 ? JSON.stringify(body) : void 0
316
- });
317
- }
318
- async function patch(path, body) {
319
- return execute({
320
- url: buildUrl(path),
321
- method: "PATCH",
322
- headers: { "Content-Type": "application/json", "Accept": "application/json" },
323
- body: body !== void 0 ? JSON.stringify(body) : void 0
324
- });
325
- }
326
- async function del(path, params) {
327
- return execute({
328
- url: buildUrl(path, params),
329
- method: "DELETE",
330
- headers: { Accept: "application/json" }
331
- });
332
- }
333
- async function upload(path, file, options) {
334
- const formData = new FormData();
335
- formData.append(options?.fieldName ?? "file", file);
336
- if (options?.extraFields) {
337
- for (const [key, value] of Object.entries(options.extraFields)) {
338
- formData.append(key, value);
339
- }
340
- }
341
- return execute({
342
- url: buildUrl(path),
343
- method: "POST",
344
- headers: { Accept: "application/json" },
345
- body: formData
346
- });
347
- }
348
- async function* stream(path, body, options) {
349
- const requestConfig = {
350
- url: buildUrl(path),
351
- method: "POST",
352
- headers: { "Content-Type": "application/json", "Accept": "text/event-stream" },
353
- body: body !== void 0 ? JSON.stringify(body) : void 0
354
- };
355
- if (tokenManager) {
356
- const accessToken = await tokenManager.storage.getAccessToken();
357
- if (accessToken) {
358
- requestConfig.headers.Authorization = `Bearer ${accessToken}`;
359
- }
360
- }
361
- let finalConfig = requestConfig;
362
- for (const interceptor of requestInterceptors) {
363
- finalConfig = await interceptor(finalConfig);
364
- }
365
- const controller = new AbortController();
366
- let timeoutId = null;
367
- let timeoutTriggered = false;
368
- const clearStreamTimeout = () => {
369
- if (timeoutId) {
370
- clearTimeout(timeoutId);
371
- timeoutId = null;
372
- }
373
- };
374
- const armStreamTimeout = () => {
375
- clearStreamTimeout();
376
- timeoutTriggered = false;
377
- timeoutId = setTimeout(() => {
378
- timeoutTriggered = true;
379
- controller.abort();
380
- }, timeout);
381
- };
382
- const externalSignal = options?.signal;
383
- const onExternalAbort = () => {
384
- controller.abort();
385
- };
386
- if (externalSignal) {
387
- if (externalSignal.aborted) {
388
- controller.abort();
389
- } else {
390
- externalSignal.addEventListener("abort", onExternalAbort, { once: true });
391
- }
392
- }
393
- async function doStreamFetch() {
394
- armStreamTimeout();
395
- try {
396
- return await fetchFn(finalConfig.url, {
397
- method: finalConfig.method,
398
- headers: finalConfig.headers,
399
- body: finalConfig.body,
400
- signal: controller.signal
401
- });
402
- } catch (cause) {
403
- clearStreamTimeout();
404
- throw cause;
405
- }
406
- }
407
- let reader = null;
408
- try {
409
- let response = await doStreamFetch();
410
- if (response.status === 401 && tokenManager) {
411
- logger.info("Stream received 401, attempting token refresh", { url: requestConfig.url });
412
- const tokens = await tokenManager.refresh();
413
- if (tokens) {
414
- finalConfig.headers.Authorization = `Bearer ${tokens.accessToken}`;
415
- response = await doStreamFetch();
416
- }
417
- }
418
- if (!response.ok) {
419
- const errorResult = await responseToError(response);
420
- if (!errorResult.success) {
421
- throw new Error(errorResult.error.message, { cause: errorResult.error });
422
- }
423
- }
424
- if (!response.body) {
425
- throw new Error(apiClientM("apiClient_networkError"));
426
- }
427
- reader = response.body.getReader();
428
- const decoder = new TextDecoder();
429
- let lineBuffer = "";
430
- while (true) {
431
- armStreamTimeout();
432
- const { done, value } = await reader.read();
433
- clearStreamTimeout();
434
- if (done)
435
- break;
436
- lineBuffer += decoder.decode(value, { stream: true });
437
- const parts = lineBuffer.split("\n");
438
- lineBuffer = parts.pop() ?? "";
439
- for (const line of parts) {
440
- if (line.startsWith("data: ")) {
441
- const data = line.slice(6);
442
- if (data !== "[DONE]") {
443
- yield data;
444
- }
445
- }
446
- }
447
- }
448
- if (lineBuffer.startsWith("data: ")) {
449
- const data = lineBuffer.slice(6);
450
- if (data !== "[DONE]") {
451
- yield data;
452
- }
453
- }
454
- } catch (cause) {
455
- if (cause instanceof DOMException && cause.name === "AbortError") {
456
- if (externalSignal?.aborted && !timeoutTriggered) {
457
- throw cause;
458
- }
459
- throw new Error(apiClientM("apiClient_timeout"), { cause });
460
- }
461
- throw cause;
462
- } finally {
463
- clearStreamTimeout();
464
- if (externalSignal) {
465
- externalSignal.removeEventListener("abort", onExternalAbort);
466
- }
467
- if (reader) {
468
- reader.releaseLock();
469
- }
55
+ function createHttpOnlyCookieTokenStorage() {
56
+ let memoryAccessToken = null;
57
+ return {
58
+ async getAccessToken() {
59
+ return memoryAccessToken;
60
+ },
61
+ async setAccessToken(token) {
62
+ memoryAccessToken = token;
63
+ },
64
+ // refresh token 由服务端通过 Set-Cookie 设置为 httpOnly cookie;
65
+ // JS 不可见,返回哨兵值让 TokenManager 识别此模式。
66
+ async getRefreshToken() {
67
+ return HTTPONLY_COOKIE_SENTINEL;
68
+ },
69
+ // refresh token 由服务端管理,JS 端无法写入 httpOnly cookie,忽略此调用。
70
+ async setRefreshToken(_token) {
71
+ },
72
+ async clear() {
73
+ memoryAccessToken = null;
470
74
  }
471
- }
472
- return { get, post, put, patch, delete: del, upload, stream, execute, buildUrl };
75
+ };
473
76
  }
474
- var logger2 = core.logger.child({ module: "api-client", scope: "auth" });
475
- var TokenPairSchema = z.object({
77
+ var RefreshTokenPayloadSchema = z.object({
476
78
  accessToken: z.string().min(1),
477
- refreshToken: z.string().min(1),
79
+ refreshToken: z.string().min(1).optional(),
478
80
  expiresIn: z.number().optional(),
479
81
  tokenType: z.string().optional()
480
82
  });
481
- function createTokenManager(storage, refreshUrl, fetchFn, onRefreshFailed) {
83
+ function createTokenManager(storage, refreshEndpointUrl, fetchFn, onRefreshFailed, refreshTimeoutMs = 1e4) {
482
84
  const callbacks = [];
483
85
  let refreshPromise = null;
484
86
  async function refresh() {
485
- if (refreshPromise) {
87
+ if (refreshPromise)
486
88
  return refreshPromise;
487
- }
488
89
  refreshPromise = doRefresh();
489
90
  try {
490
91
  return await refreshPromise;
@@ -495,48 +96,65 @@ function createTokenManager(storage, refreshUrl, fetchFn, onRefreshFailed) {
495
96
  async function doRefresh() {
496
97
  const refreshToken = await storage.getRefreshToken();
497
98
  if (!refreshToken) {
498
- logger2.warn("Token refresh skipped, no refresh token available");
99
+ logger.warn("Token refresh skipped, no refresh token available");
499
100
  onRefreshFailed?.();
500
101
  return null;
501
102
  }
502
- logger2.debug("Refreshing token", { url: refreshUrl });
103
+ logger.debug("Refreshing token");
104
+ const controller = new AbortController();
105
+ const timeoutId = setTimeout(() => controller.abort(), refreshTimeoutMs);
503
106
  try {
504
- const response = await fetchFn(refreshUrl, {
107
+ const useHttpOnlyCookie = refreshToken === HTTPONLY_COOKIE_SENTINEL;
108
+ const requestInit = {
505
109
  method: "POST",
506
- headers: { "Content-Type": "application/json" },
507
- body: JSON.stringify({ refreshToken })
508
- });
110
+ body: useHttpOnlyCookie ? void 0 : JSON.stringify({ refreshToken }),
111
+ credentials: useHttpOnlyCookie ? "include" : "same-origin",
112
+ signal: controller.signal
113
+ };
114
+ if (!useHttpOnlyCookie) {
115
+ requestInit.headers = { "Content-Type": "application/json" };
116
+ }
117
+ const response = await fetchFn(refreshEndpointUrl, requestInit);
509
118
  if (!response.ok) {
510
- await storage.clear();
511
- onRefreshFailed?.();
119
+ if (response.status >= 400 && response.status < 500) {
120
+ await storage.clear();
121
+ onRefreshFailed?.();
122
+ } else {
123
+ logger.warn("Token refresh transient failure, keeping tokens", { status: response.status });
124
+ }
512
125
  return null;
513
126
  }
514
127
  const body = await response.json();
515
- const parsed = TokenPairSchema.safeParse(body.data);
128
+ const parsed = RefreshTokenPayloadSchema.safeParse(readTokenPayload(body.data));
516
129
  if (!parsed.success) {
517
- logger2.warn("Token refresh returned invalid data", { issues: parsed.error.issues });
130
+ logger.warn("Token refresh returned invalid data", { issues: parsed.error.issues });
131
+ await storage.clear();
132
+ onRefreshFailed?.();
133
+ return null;
134
+ }
135
+ if (!useHttpOnlyCookie && !parsed.data.refreshToken) {
136
+ logger.warn("Token refresh returned invalid data", { issues: [{ path: ["refreshToken"], message: "Required" }] });
518
137
  await storage.clear();
519
138
  onRefreshFailed?.();
520
139
  return null;
521
140
  }
141
+ const refreshTokenForStorage = parsed.data.refreshToken ?? HTTPONLY_COOKIE_SENTINEL;
522
142
  const tokens = {
523
143
  accessToken: parsed.data.accessToken,
524
- refreshToken: parsed.data.refreshToken,
144
+ refreshToken: refreshTokenForStorage,
525
145
  expiresIn: parsed.data.expiresIn ?? 3600,
526
146
  tokenType: "Bearer"
527
147
  };
528
148
  await storage.setAccessToken(tokens.accessToken);
529
- await storage.setRefreshToken(tokens.refreshToken);
530
- logger2.info("Token refreshed successfully");
531
- for (const cb of callbacks) {
532
- cb(tokens);
533
- }
149
+ await storage.setRefreshToken(refreshTokenForStorage);
150
+ logger.info("Token refreshed successfully");
151
+ for (const cb of callbacks) cb(tokens);
534
152
  return tokens;
535
153
  } catch (error) {
536
- logger2.error("Token refresh failed", { error });
537
- await storage.clear();
538
- onRefreshFailed?.();
154
+ logger.error("Token refresh failed (transient)", { error });
539
155
  return null;
156
+ } finally {
157
+ clearTimeout(timeoutId);
540
158
  }
541
159
  }
542
160
  return {
@@ -544,7 +162,8 @@ function createTokenManager(storage, refreshUrl, fetchFn, onRefreshFailed) {
544
162
  refresh,
545
163
  async setTokens(tokens) {
546
164
  await storage.setAccessToken(tokens.accessToken);
547
- await storage.setRefreshToken(tokens.refreshToken);
165
+ if (tokens.refreshToken)
166
+ await storage.setRefreshToken(tokens.refreshToken);
548
167
  },
549
168
  async clear() {
550
169
  await storage.clear();
@@ -559,192 +178,283 @@ function createTokenManager(storage, refreshUrl, fetchFn, onRefreshFailed) {
559
178
  }
560
179
  };
561
180
  }
181
+ function readTokenPayload(data) {
182
+ if (typeof data === "object" && data !== null && "tokens" in data) {
183
+ return data.tokens;
184
+ }
185
+ return data;
186
+ }
187
+
188
+ // messages/en-US.json
189
+ var en_US_default = {
190
+ apiClient_networkError: "Network request failed",
191
+ apiClient_timeout: "Request timeout",
192
+ apiClient_serverError: "Server error ({status})",
193
+ apiClient_unauthorized: "Unauthorized, please log in again",
194
+ apiClient_forbidden: "Access forbidden",
195
+ apiClient_notFound: "Resource not found",
196
+ apiClient_validationFailed: "Request validation failed",
197
+ apiClient_tokenRefreshFailed: "Token refresh failed, please log in again",
198
+ apiClient_notInitialized: "API client not initialized, please call api.init() first",
199
+ apiClient_configError: "API client config error: {error}",
200
+ apiClient_responseValidationFailed: "Response validation failed",
201
+ apiClient_unknown: "Unknown error"
202
+ };
203
+
204
+ // messages/zh-CN.json
205
+ var zh_CN_default = {
206
+ apiClient_networkError: "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25",
207
+ apiClient_timeout: "\u8BF7\u6C42\u8D85\u65F6",
208
+ apiClient_serverError: "\u670D\u52A1\u5668\u9519\u8BEF\uFF08{status}\uFF09",
209
+ apiClient_unauthorized: "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
210
+ apiClient_forbidden: "\u65E0\u6743\u9650\u8BBF\u95EE",
211
+ apiClient_notFound: "\u8BF7\u6C42\u7684\u8D44\u6E90\u4E0D\u5B58\u5728",
212
+ apiClient_validationFailed: "\u8BF7\u6C42\u53C2\u6570\u6821\u9A8C\u5931\u8D25",
213
+ apiClient_tokenRefreshFailed: "Token\u5237\u65B0\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
214
+ apiClient_notInitialized: "API \u5BA2\u6237\u7AEF\u672A\u521D\u59CB\u5316\uFF0C\u8BF7\u5148\u8C03\u7528 api.init()",
215
+ apiClient_configError: "API \u5BA2\u6237\u7AEF\u914D\u7F6E\u9519\u8BEF\uFF1A{error}",
216
+ apiClient_responseValidationFailed: "\u54CD\u5E94\u6570\u636E\u6821\u9A8C\u5931\u8D25",
217
+ apiClient_unknown: "\u672A\u77E5\u9519\u8BEF"
218
+ };
219
+
220
+ // src/api-client-i18n.ts
221
+ var apiClientM = core.i18n.createMessageGetter({
222
+ "zh-CN": zh_CN_default,
223
+ "en-US": en_US_default
224
+ });
225
+ var ApiClientErrorInfo = {
226
+ NETWORK_ERROR: "001:502",
227
+ TIMEOUT: "002:504",
228
+ SERVER_ERROR: "003:502",
229
+ UNAUTHORIZED: "004:401",
230
+ FORBIDDEN: "005:403",
231
+ NOT_FOUND: "006:404",
232
+ VALIDATION_FAILED: "007:400",
233
+ TOKEN_REFRESH_FAILED: "008:401",
234
+ NOT_INITIALIZED: "010:500",
235
+ CONFIG_ERROR: "011:500",
236
+ UNKNOWN: "099:500"
237
+ };
238
+ var HaiApiClientError = core.error.buildHaiErrorsDef("api-client", ApiClientErrorInfo);
562
239
 
563
240
  // src/api-client-main.ts
564
- var logger3 = core.logger.child({ module: "api-client", scope: "main" });
565
- var TRAILING_SLASHES_REGEX2 = /\/+$/;
566
- var currentClient = null;
567
- var currentConfig = null;
568
- var initInProgress = false;
569
- function createClient(config) {
570
- const fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
571
- const tokenStorage = config.auth?.storage ?? createLocalStorageTokenStorage();
572
- const tokenManager = config.auth ? createTokenManager(
573
- tokenStorage,
574
- `${config.baseUrl.replace(TRAILING_SLASHES_REGEX2, "")}${config.auth.refreshUrl}`,
575
- fetchFn,
576
- config.auth.onRefreshFailed
577
- ) : void 0;
578
- if (config.auth?.onTokenRefreshed && tokenManager) {
579
- tokenManager.onTokenRefreshed(config.auth.onTokenRefreshed);
580
- }
581
- const fetchClient = createFetchClient(config, tokenManager);
582
- const call = createContractCaller(fetchClient);
241
+ var DEFAULT_TIMEOUT = 3e4;
242
+ var DEFAULT_CLIENT_NAME = "hai-api-client";
243
+ var DEFAULT_REFRESH_PATH = IAM_AUTH_ROUTES.refresh;
244
+ var TRAILING_SLASHES_REGEX = /\/+$/;
245
+ var tokenStorage = {
246
+ /** 内存存储——SSR / Node 测试或一次性会话使用。 */
247
+ memory: createMemoryTokenStorage,
248
+ /** 浏览器 `localStorage` 存储——单页应用便捷选项;注意 XSS 风险。 */
249
+ localStorage: createLocalStorageTokenStorage,
250
+ /** httpOnly Cookie 存储——浏览器场景推荐方案。 */
251
+ httpOnlyCookie: createHttpOnlyCookieTokenStorage
252
+ };
253
+ function createApiClient(contract) {
254
+ const state = {
255
+ config: null,
256
+ rawClient: null,
257
+ tokenManager: void 0,
258
+ transport: void 0
259
+ };
583
260
  const auth = {
584
261
  async setTokens(tokens) {
585
- if (tokenManager) {
586
- await tokenManager.setTokens(tokens);
587
- }
262
+ await state.tokenManager?.setTokens(tokens);
588
263
  },
589
264
  async clear() {
590
- if (tokenManager) {
591
- await tokenManager.clear();
592
- }
265
+ await state.tokenManager?.clear();
593
266
  },
594
267
  onTokenRefreshed(callback) {
595
- if (tokenManager) {
596
- return tokenManager.onTokenRefreshed(callback);
597
- }
598
- return () => {
599
- };
268
+ return state.tokenManager?.onTokenRefreshed(callback) ?? (() => {
269
+ });
600
270
  }
601
271
  };
602
- return {
603
- get: fetchClient.get,
604
- post: fetchClient.post,
605
- put: fetchClient.put,
606
- patch: fetchClient.patch,
607
- delete: fetchClient.delete,
608
- upload: fetchClient.upload,
609
- stream: fetchClient.stream,
610
- call,
272
+ let initPromise = null;
273
+ const lifecycle = {
274
+ async init(config) {
275
+ if (initPromise)
276
+ return initPromise;
277
+ initPromise = (async () => {
278
+ try {
279
+ state.config = config;
280
+ state.transport = buildTransportSession(config);
281
+ state.tokenManager = buildTokenManager(config, state.transport);
282
+ state.rawClient = buildRawClient(contract, config, state.tokenManager, state.transport);
283
+ return ok(void 0);
284
+ } catch (error) {
285
+ return err(
286
+ HaiApiClientError.CONFIG_ERROR,
287
+ apiClientM("apiClient_configError", { params: { error: String(error) } }),
288
+ error
289
+ );
290
+ }
291
+ })();
292
+ try {
293
+ return await initPromise;
294
+ } finally {
295
+ initPromise = null;
296
+ }
297
+ },
298
+ async close() {
299
+ state.transport?.destroy();
300
+ state.config = null;
301
+ state.rawClient = null;
302
+ state.tokenManager = void 0;
303
+ state.transport = void 0;
304
+ },
305
+ get config() {
306
+ return state.config;
307
+ },
308
+ get isInitialized() {
309
+ return state.rawClient !== null;
310
+ },
611
311
  auth
612
312
  };
313
+ return new Proxy({}, {
314
+ get(_target, property) {
315
+ if (typeof property !== "string")
316
+ return void 0;
317
+ if (property in lifecycle) {
318
+ return Reflect.get(lifecycle, property);
319
+ }
320
+ return createProcedureProxy(state, [property]);
321
+ }
322
+ // 这里的返回值在运行时是一个 Proxy,但从“可用能力”上看,它符合 `ApiClient<TContract>`:
323
+ // - 一部分成员来自 lifecycle
324
+ // - 另一部分成员来自 contract 推导出的 procedure 层级
325
+ // 因此使用类型断言把这个动态对象声明为最终对外的 typed client。
326
+ });
613
327
  }
614
- var notInitialized = core.module.createNotInitializedKit(
615
- HaiApiClientError.NOT_INITIALIZED,
616
- () => apiClientM("apiClient_notInitialized")
617
- );
618
- var notInitializedOps = notInitialized.proxy();
619
- var notInitializedAuth = {
620
- async setTokens() {
621
- logger3.warn("api.auth.setTokens called before initialization, ignored");
622
- },
623
- async clear() {
624
- logger3.warn("api.auth.clear called before initialization, ignored");
625
- },
626
- onTokenRefreshed() {
627
- logger3.warn("api.auth.onTokenRefreshed called before initialization, ignored");
628
- return () => {
629
- };
328
+ var defaultApiContract = apiContract.create({ iam: apiContract.iam, storage: apiContract.storage, ai: apiContract.ai });
329
+ var apiClient = new Proxy(createApiClient(defaultApiContract), {
330
+ get(target, property, receiver) {
331
+ if (property === "create")
332
+ return createApiClient;
333
+ if (property === "tokenStorage")
334
+ return tokenStorage;
335
+ return Reflect.get(target, property, receiver);
630
336
  }
631
- };
632
- function notInitializedStreamFn(_path, _body, _options) {
633
- return {
634
- [Symbol.asyncIterator]() {
635
- return {
636
- async next() {
637
- throw new Error(apiClientM("apiClient_notInitialized"));
638
- }
639
- };
337
+ });
338
+ function createProcedureProxy(state, path) {
339
+ return new Proxy(() => void 0, {
340
+ get(_t, property) {
341
+ if (typeof property !== "string")
342
+ return void 0;
343
+ return createProcedureProxy(state, [...path, property]);
344
+ },
345
+ apply(_t, _thisArg, args) {
346
+ return callProcedure(state.rawClient, path, args);
640
347
  }
641
- };
348
+ });
642
349
  }
643
- var api = {
644
- /**
645
- * 初始化 API 客户端
646
- *
647
- * 如果当前已有活跃实例,会先自动 close 再重新初始化。
648
- * 创建异常时返回 CONFIG_ERROR 错误。
649
- *
650
- * @param config 客户端配置(baseUrl、auth 等)
651
- * @returns 成功时返回 ok(undefined);失败时返回包含错误码和消息的 err。
652
- */
653
- async init(config) {
654
- if (initInProgress) {
655
- return err(
656
- HaiApiClientError.CONFIG_ERROR,
657
- apiClientM("apiClient_configError", { params: { error: "initialization already in progress" } })
658
- );
659
- }
660
- initInProgress = true;
661
- try {
662
- if (currentClient) {
663
- logger3.warn("Api-client module is already initialized, reinitializing");
664
- await api.close();
665
- }
666
- logger3.info("Initializing api-client module");
667
- currentClient = createClient(config);
668
- currentConfig = config;
669
- logger3.info("Api-client module initialized");
670
- return ok(void 0);
671
- } catch (error) {
672
- logger3.error("Api-client module initialization failed", { error });
673
- return err(
674
- HaiApiClientError.CONFIG_ERROR,
675
- apiClientM("apiClient_configError", { params: { error: error instanceof Error ? error.message : String(error) } }),
676
- error
677
- );
678
- } finally {
679
- initInProgress = false;
350
+ async function callProcedure(rawClient, path, args) {
351
+ if (!rawClient) {
352
+ return err(HaiApiClientError.NOT_INITIALIZED, apiClientM("apiClient_notInitialized"));
353
+ }
354
+ let target = rawClient;
355
+ for (const segment of path) {
356
+ if ((typeof target !== "object" || target === null) && typeof target !== "function") {
357
+ return err(HaiApiClientError.UNKNOWN, apiClientM("apiClient_unknown"));
680
358
  }
681
- },
682
- /** GET 请求。未初始化时返回 NOT_INITIALIZED 错误 */
683
- get get() {
684
- return currentClient?.get ?? notInitializedOps.get;
685
- },
686
- /** POST 请求。未初始化时返回 NOT_INITIALIZED 错误 */
687
- get post() {
688
- return currentClient?.post ?? notInitializedOps.post;
689
- },
690
- /** PUT 请求。未初始化时返回 NOT_INITIALIZED 错误 */
691
- get put() {
692
- return currentClient?.put ?? notInitializedOps.put;
693
- },
694
- /** PATCH 请求。未初始化时返回 NOT_INITIALIZED 错误 */
695
- get patch() {
696
- return currentClient?.patch ?? notInitializedOps.patch;
697
- },
698
- /** DELETE 请求。未初始化时返回 NOT_INITIALIZED 错误 */
699
- get delete() {
700
- return currentClient?.delete ?? notInitializedOps.delete;
701
- },
702
- /** 文件上传。未初始化时返回 NOT_INITIALIZED 错误 */
703
- get upload() {
704
- return currentClient?.upload ?? notInitializedOps.upload;
705
- },
706
- /** 契约调用。未初始化时返回 NOT_INITIALIZED 错误 */
707
- get call() {
708
- return currentClient?.call ?? notInitializedOps.call;
709
- },
710
- /**
711
- * 流式请求。未初始化时迭代抛出异常。
712
- *
713
- * @throws 未初始化时抛出 Error
714
- */
715
- get stream() {
716
- return currentClient?.stream ?? notInitializedStreamFn;
717
- },
718
- /** Token 管理接口。未初始化时为空操作 */
719
- get auth() {
720
- return currentClient?.auth ?? notInitializedAuth;
721
- },
722
- /** 当前客户端配置;未初始化或已关闭时为 null */
723
- get config() {
724
- return currentConfig;
725
- },
726
- /** 是否已完成初始化 */
727
- get isInitialized() {
728
- return currentClient !== null;
729
- },
730
- /**
731
- * 关闭 API 客户端并释放资源
732
- *
733
- * 关闭后所有 HTTP 操作将返回 NOT_INITIALIZED 错误。
734
- * 重复调用不会报错。
735
- */
736
- async close() {
737
- if (!currentClient) {
738
- currentConfig = null;
739
- return;
359
+ target = Reflect.get(target, segment);
360
+ }
361
+ if (typeof target !== "function") {
362
+ return err(HaiApiClientError.UNKNOWN, apiClientM("apiClient_unknown"));
363
+ }
364
+ try {
365
+ return await target(...args);
366
+ } catch (error) {
367
+ return mapClientError(error);
368
+ }
369
+ }
370
+ function buildTokenManager(config, transport) {
371
+ if (!config.auth)
372
+ return void 0;
373
+ const fetchFn = transport?.encryptedFetch ?? config.fetch ?? globalThis.fetch.bind(globalThis);
374
+ const storage = config.auth.storage ?? createHttpOnlyCookieTokenStorage();
375
+ const baseUrl = config.baseUrl.replace(TRAILING_SLASHES_REGEX, "");
376
+ const refreshPath = config.auth.refreshPath ?? DEFAULT_REFRESH_PATH;
377
+ const refreshUrl = `${baseUrl}${refreshPath.startsWith("/") ? refreshPath : `/${refreshPath}`}`;
378
+ const manager = createTokenManager(storage, refreshUrl, fetchFn, config.auth.onRefreshFailed, config.auth.refreshTimeoutMs);
379
+ if (config.auth.onTokenRefreshed) {
380
+ manager.onTokenRefreshed(config.auth.onTokenRefreshed);
381
+ }
382
+ return manager;
383
+ }
384
+ function buildTransportSession(config) {
385
+ if (!config.transport)
386
+ return void 0;
387
+ const baseFetch = config.fetch ?? globalThis.fetch.bind(globalThis);
388
+ const baseUrl = config.baseUrl.replace(TRAILING_SLASHES_REGEX, "");
389
+ const path = config.transport.keyExchangePath ?? "/_hai/key-exchange";
390
+ const keyExchangeUrl = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
391
+ return config.transport.crypto.transport.createClient({ keyExchangeUrl, fetch: baseFetch });
392
+ }
393
+ function buildRawClient(contract, config, tokenManager, transport) {
394
+ const link = new OpenAPILink(contract, {
395
+ url: config.baseUrl,
396
+ headers: async () => buildHeaders(config, tokenManager),
397
+ fetch: async (request, init) => fetchWithRefresh(request, init, config, tokenManager, transport)
398
+ });
399
+ return createORPCClient(link);
400
+ }
401
+ async function buildHeaders(config, tokenManager) {
402
+ const configured = typeof config.headers === "function" ? await config.headers() : config.headers ?? {};
403
+ const accessToken = await tokenManager?.storage.getAccessToken();
404
+ return {
405
+ ...configured,
406
+ "x-request-client": config.clientName ?? DEFAULT_CLIENT_NAME,
407
+ ...accessToken ? { authorization: `Bearer ${accessToken}` } : {}
408
+ };
409
+ }
410
+ async function fetchWithRefresh(request, init, config, tokenManager, transport) {
411
+ const retryRequest = tokenManager ? request.clone() : void 0;
412
+ const response = await fetchWithTimeout(request, init, config, transport);
413
+ if (response.status !== 401 || !tokenManager || !retryRequest)
414
+ return response;
415
+ const tokens = await tokenManager.refresh();
416
+ if (!tokens)
417
+ return response;
418
+ const headers = new Headers(retryRequest.headers);
419
+ headers.set("authorization", `Bearer ${tokens.accessToken}`);
420
+ return fetchWithTimeout(new Request(retryRequest, { headers }), init, config, transport);
421
+ }
422
+ async function fetchWithTimeout(request, init, config, transport) {
423
+ const fetchFn = transport?.encryptedFetch ?? config.fetch ?? globalThis.fetch.bind(globalThis);
424
+ const timeout = config.timeout ?? DEFAULT_TIMEOUT;
425
+ const controller = new AbortController();
426
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
427
+ const userSignal = request.signal;
428
+ if (userSignal) {
429
+ if (userSignal.aborted) {
430
+ controller.abort();
431
+ } else {
432
+ userSignal.addEventListener("abort", () => controller.abort(), { once: true });
740
433
  }
741
- logger3.info("Closing api-client module");
742
- currentClient = null;
743
- currentConfig = null;
744
- logger3.info("Api-client module closed");
745
434
  }
746
- };
435
+ try {
436
+ return await fetchFn(new Request(request, { signal: controller.signal }), init);
437
+ } finally {
438
+ clearTimeout(timeoutId);
439
+ }
440
+ }
441
+ function mapClientError(error) {
442
+ if (error instanceof ORPCError) {
443
+ const detail = { orpcCode: error.code, status: error.status, data: error.data };
444
+ if (error.status === 401)
445
+ return err(HaiApiClientError.UNAUTHORIZED, error.message, detail);
446
+ if (error.status === 403)
447
+ return err(HaiApiClientError.FORBIDDEN, error.message, detail);
448
+ if (error.status === 404)
449
+ return err(HaiApiClientError.NOT_FOUND, error.message, detail);
450
+ return err(HaiApiClientError.SERVER_ERROR, error.message, detail);
451
+ }
452
+ if (error instanceof DOMException && error.name === "AbortError") {
453
+ return err(HaiApiClientError.TIMEOUT, apiClientM("apiClient_timeout"), error);
454
+ }
455
+ return err(HaiApiClientError.NETWORK_ERROR, apiClientM("apiClient_networkError"), error);
456
+ }
747
457
 
748
- export { HaiApiClientError, api, createLocalStorageTokenStorage, createMemoryTokenStorage, defineEndpoint };
458
+ export { HaiApiClientError, apiClient };
749
459
  //# sourceMappingURL=index.js.map
750
460
  //# sourceMappingURL=index.js.map