@h-ai/api-client 0.1.0-alpha.67 → 0.1.0-alpha.69

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,5 +1,6 @@
1
1
  import { apiContract } from '@h-ai/api-contract';
2
2
  import { core, err, ok } from '@h-ai/core';
3
+ import { TransportTimeError, TransportBodyTooLargeError } from '@h-ai/crypto';
3
4
  import { createORPCClient, ORPCError } from '@orpc/client';
4
5
  import { OpenAPILink } from '@orpc/openapi-client/fetch';
5
6
  import { z } from 'zod';
@@ -7,29 +8,6 @@ import { z } from 'zod';
7
8
  // src/api-client-main.ts
8
9
  var logger = core.logger.child({ module: "api-client", scope: "auth" });
9
10
  var HTTPONLY_COOKIE_SENTINEL = "__httponly_cookie__";
10
- var LS_ACCESS_KEY = "hai_access_token";
11
- var LS_REFRESH_KEY = "hai_refresh_token";
12
- function createLocalStorageTokenStorage() {
13
- logger.warn("apiClient.tokenStorage.localStorage: storing tokens in localStorage exposes them to XSS. Prefer httpOnly cookies for production.");
14
- return {
15
- async getAccessToken() {
16
- return globalThis.localStorage?.getItem(LS_ACCESS_KEY) ?? null;
17
- },
18
- async setAccessToken(token) {
19
- globalThis.localStorage?.setItem(LS_ACCESS_KEY, token);
20
- },
21
- async getRefreshToken() {
22
- return globalThis.localStorage?.getItem(LS_REFRESH_KEY) ?? null;
23
- },
24
- async setRefreshToken(token) {
25
- globalThis.localStorage?.setItem(LS_REFRESH_KEY, token);
26
- },
27
- async clear() {
28
- globalThis.localStorage?.removeItem(LS_ACCESS_KEY);
29
- globalThis.localStorage?.removeItem(LS_REFRESH_KEY);
30
- }
31
- };
32
- }
33
11
  function createMemoryTokenStorage() {
34
12
  let accessToken = null;
35
13
  let refreshToken = null;
@@ -83,25 +61,61 @@ var RefreshTokenPayloadSchema = z.object({
83
61
  function createTokenManager(storage, refreshEndpointUrl, fetchFn, onRefreshFailed, refreshTimeoutMs = 1e4) {
84
62
  const callbacks = [];
85
63
  let refreshPromise = null;
64
+ let refreshController = null;
65
+ let generation = 0;
66
+ let storageMutation = Promise.resolve();
67
+ function mutateStorage(operation) {
68
+ const pending = storageMutation.then(operation);
69
+ storageMutation = pending.then(() => {
70
+ }, () => {
71
+ });
72
+ return pending;
73
+ }
74
+ function invalidateRefresh() {
75
+ const previousController = refreshController;
76
+ generation++;
77
+ refreshController = null;
78
+ refreshPromise = null;
79
+ return previousController;
80
+ }
81
+ async function failRefresh(refreshGeneration) {
82
+ await mutateStorage(async () => {
83
+ if (refreshGeneration !== generation)
84
+ return;
85
+ await storage.clear();
86
+ if (refreshGeneration === generation)
87
+ onRefreshFailed?.();
88
+ });
89
+ }
86
90
  async function refresh() {
87
91
  if (refreshPromise)
88
92
  return refreshPromise;
89
- refreshPromise = doRefresh();
93
+ const controller = new AbortController();
94
+ const pending = doRefresh(generation, controller);
95
+ refreshController = controller;
96
+ refreshPromise = pending;
90
97
  try {
91
- return await refreshPromise;
98
+ return await pending;
92
99
  } finally {
93
- refreshPromise = null;
100
+ if (refreshPromise === pending) {
101
+ refreshPromise = null;
102
+ refreshController = null;
103
+ }
94
104
  }
95
105
  }
96
- async function doRefresh() {
106
+ async function doRefresh(refreshGeneration, controller) {
107
+ await storageMutation;
108
+ if (refreshGeneration !== generation)
109
+ return null;
97
110
  const refreshToken = await storage.getRefreshToken();
111
+ if (refreshGeneration !== generation)
112
+ return null;
98
113
  if (!refreshToken) {
99
114
  logger.warn("Token refresh skipped, no refresh token available");
100
115
  onRefreshFailed?.();
101
116
  return null;
102
117
  }
103
118
  logger.debug("Refreshing token");
104
- const controller = new AbortController();
105
119
  const timeoutId = setTimeout(() => controller.abort(), refreshTimeoutMs);
106
120
  try {
107
121
  const useHttpOnlyCookie = refreshToken === HTTPONLY_COOKIE_SENTINEL;
@@ -115,27 +129,28 @@ function createTokenManager(storage, refreshEndpointUrl, fetchFn, onRefreshFaile
115
129
  requestInit.headers = { "Content-Type": "application/json" };
116
130
  }
117
131
  const response = await fetchFn(refreshEndpointUrl, requestInit);
132
+ if (refreshGeneration !== generation)
133
+ return null;
118
134
  if (!response.ok) {
119
135
  if (response.status >= 400 && response.status < 500) {
120
- await storage.clear();
121
- onRefreshFailed?.();
136
+ await failRefresh(refreshGeneration);
122
137
  } else {
123
138
  logger.warn("Token refresh transient failure, keeping tokens", { status: response.status });
124
139
  }
125
140
  return null;
126
141
  }
127
142
  const body = await response.json();
143
+ if (refreshGeneration !== generation)
144
+ return null;
128
145
  const parsed = RefreshTokenPayloadSchema.safeParse(readTokenPayload(body.data));
129
146
  if (!parsed.success) {
130
147
  logger.warn("Token refresh returned invalid data", { issues: parsed.error.issues });
131
- await storage.clear();
132
- onRefreshFailed?.();
148
+ await failRefresh(refreshGeneration);
133
149
  return null;
134
150
  }
135
151
  if (!useHttpOnlyCookie && !parsed.data.refreshToken) {
136
152
  logger.warn("Token refresh returned invalid data", { issues: [{ path: ["refreshToken"], message: "Required" }] });
137
- await storage.clear();
138
- onRefreshFailed?.();
153
+ await failRefresh(refreshGeneration);
139
154
  return null;
140
155
  }
141
156
  const refreshTokenForStorage = parsed.data.refreshToken ?? HTTPONLY_COOKIE_SENTINEL;
@@ -145,12 +160,27 @@ function createTokenManager(storage, refreshEndpointUrl, fetchFn, onRefreshFaile
145
160
  expiresIn: parsed.data.expiresIn ?? 3600,
146
161
  tokenType: "Bearer"
147
162
  };
148
- await storage.setAccessToken(tokens.accessToken);
149
- await storage.setRefreshToken(refreshTokenForStorage);
163
+ const committed = await mutateStorage(async () => {
164
+ if (refreshGeneration !== generation)
165
+ return false;
166
+ await storage.setAccessToken(tokens.accessToken);
167
+ if (refreshGeneration !== generation)
168
+ return false;
169
+ await storage.setRefreshToken(refreshTokenForStorage);
170
+ return refreshGeneration === generation;
171
+ });
172
+ if (!committed || refreshGeneration !== generation)
173
+ return null;
150
174
  logger.info("Token refreshed successfully");
151
- for (const cb of callbacks) cb(tokens);
152
- return tokens;
175
+ for (const cb of callbacks) {
176
+ if (refreshGeneration !== generation)
177
+ return null;
178
+ cb(tokens);
179
+ }
180
+ return refreshGeneration === generation ? tokens : null;
153
181
  } catch (error) {
182
+ if (refreshGeneration !== generation)
183
+ return null;
154
184
  logger.error("Token refresh failed (transient)", { error });
155
185
  return null;
156
186
  } finally {
@@ -161,12 +191,26 @@ function createTokenManager(storage, refreshEndpointUrl, fetchFn, onRefreshFaile
161
191
  storage,
162
192
  refresh,
163
193
  async setTokens(tokens) {
164
- await storage.setAccessToken(tokens.accessToken);
165
- if (tokens.refreshToken)
166
- await storage.setRefreshToken(tokens.refreshToken);
194
+ const previousController = invalidateRefresh();
195
+ const mutationGeneration = generation;
196
+ const pending = mutateStorage(async () => {
197
+ if (mutationGeneration !== generation)
198
+ return;
199
+ await storage.clear();
200
+ if (mutationGeneration !== generation)
201
+ return;
202
+ await storage.setAccessToken(tokens.accessToken);
203
+ if (tokens.refreshToken && mutationGeneration === generation)
204
+ await storage.setRefreshToken(tokens.refreshToken);
205
+ });
206
+ previousController?.abort();
207
+ await pending;
167
208
  },
168
209
  async clear() {
169
- await storage.clear();
210
+ const previousController = invalidateRefresh();
211
+ const pending = mutateStorage(() => storage.clear());
212
+ previousController?.abort();
213
+ await pending;
170
214
  },
171
215
  onTokenRefreshed(callback) {
172
216
  callbacks.push(callback);
@@ -202,6 +246,23 @@ var en_US_default = {
202
246
  apiClient_unknown: "Unknown error"
203
247
  };
204
248
 
249
+ // messages/ja-JP.json
250
+ var ja_JP_default = {
251
+ apiClient_networkError: "\u30CD\u30C3\u30C8\u30EF\u30FC\u30AF\u30EA\u30AF\u30A8\u30B9\u30C8\u306B\u5931\u6557\u3057\u307E\u3057\u305F",
252
+ apiClient_timeout: "\u30EA\u30AF\u30A8\u30B9\u30C8\u304C\u30BF\u30A4\u30E0\u30A2\u30A6\u30C8\u3057\u307E\u3057\u305F",
253
+ apiClient_serverError: "\u30B5\u30FC\u30D0\u30FC\u30A8\u30E9\u30FC\uFF08{status}\uFF09",
254
+ apiClient_unauthorized: "\u8A8D\u8A3C\u3055\u308C\u3066\u3044\u307E\u305B\u3093\u3002\u518D\u5EA6\u30ED\u30B0\u30A4\u30F3\u3057\u3066\u304F\u3060\u3055\u3044",
255
+ apiClient_forbidden: "\u30A2\u30AF\u30BB\u30B9\u304C\u62D2\u5426\u3055\u308C\u307E\u3057\u305F",
256
+ apiClient_notFound: "\u30EA\u30BD\u30FC\u30B9\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093",
257
+ apiClient_validationFailed: "\u30EA\u30AF\u30A8\u30B9\u30C8\u306E\u691C\u8A3C\u306B\u5931\u6557\u3057\u307E\u3057\u305F",
258
+ apiClient_tokenRefreshFailed: "\u30C8\u30FC\u30AF\u30F3\u306E\u66F4\u65B0\u306B\u5931\u6557\u3057\u307E\u3057\u305F\u3002\u518D\u5EA6\u30ED\u30B0\u30A4\u30F3\u3057\u3066\u304F\u3060\u3055\u3044",
259
+ apiClient_notInitialized: "API\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u304C\u521D\u671F\u5316\u3055\u308C\u3066\u3044\u307E\u305B\u3093\u3002\u5148\u306BapiClient.init()\u3092\u547C\u3073\u51FA\u3057\u3066\u304F\u3060\u3055\u3044",
260
+ apiClient_alreadyInitialized: "API\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u306F\u521D\u671F\u5316\u6E08\u307F\u3067\u3059\u3002init()\u306E\u524D\u306BapiClient.close()\u3092\u547C\u3073\u51FA\u3057\u3066\u304F\u3060\u3055\u3044",
261
+ apiClient_configError: "API\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u306E\u8A2D\u5B9A\u30A8\u30E9\u30FC: {error}",
262
+ apiClient_responseValidationFailed: "\u30EC\u30B9\u30DD\u30F3\u30B9\u306E\u691C\u8A3C\u306B\u5931\u6557\u3057\u307E\u3057\u305F",
263
+ apiClient_unknown: "\u4E0D\u660E\u306A\u30A8\u30E9\u30FC"
264
+ };
265
+
205
266
  // messages/zh-CN.json
206
267
  var zh_CN_default = {
207
268
  apiClient_networkError: "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25",
@@ -222,7 +283,8 @@ var zh_CN_default = {
222
283
  // src/api-client-i18n.ts
223
284
  var apiClientM = core.i18n.createMessageGetter({
224
285
  "zh-CN": zh_CN_default,
225
- "en-US": en_US_default
286
+ "en-US": en_US_default,
287
+ "ja-JP": ja_JP_default
226
288
  });
227
289
 
228
290
  // src/api-client-iam.ts
@@ -235,7 +297,7 @@ function createIamAuthActions(client) {
235
297
  return {
236
298
  publicConfig: () => client.iam.auth.publicConfig(),
237
299
  login: async (input) => {
238
- const result = input.method === "ldap" ? await client.iam.auth.loginWithLdap({ username: input.identifier, password: input.password }) : input.method === "email" || input.method === "sms" ? await client.iam.auth.loginWithOtp({ identifier: input.identifier, code: input.code ?? "" }) : await client.iam.auth.login({ identifier: input.identifier, password: input.password, rememberMe: input.rememberMe });
300
+ const result = input.method === "ldap" ? await client.iam.auth.loginWithLdap({ username: input.identifier, password: input.password }) : input.method === "email" || input.method === "sms" ? await client.iam.auth.loginWithOtp({ identifier: input.identifier, code: input.code ?? "" }) : await client.iam.auth.login({ identifier: input.identifier, password: input.password });
239
301
  return save(result);
240
302
  },
241
303
  register: async (input) => save(await client.iam.auth.register(input)),
@@ -253,6 +315,7 @@ var ApiClientErrorInfo = {
253
315
  NOT_FOUND: "006:404",
254
316
  VALIDATION_FAILED: "007:400",
255
317
  TOKEN_REFRESH_FAILED: "008:401",
318
+ PAYLOAD_TOO_LARGE: "020:413",
256
319
  NOT_INITIALIZED: "010:500",
257
320
  CONFIG_ERROR: "011:500",
258
321
  UNKNOWN: "099:500"
@@ -272,11 +335,10 @@ var AUTH_ENTRY_PATHS = [
272
335
  apiContract.pathOf(apiContract.iam.auth.register)
273
336
  ];
274
337
  var TRAILING_SLASHES_REGEX = /\/+$/;
338
+ var REQUEST_OBSERVATION = /* @__PURE__ */ Symbol("hai.api-client.request-observation");
275
339
  var tokenStorage = {
276
340
  /** 内存存储——SSR / Node 测试或一次性会话使用。 */
277
341
  memory: createMemoryTokenStorage,
278
- /** 浏览器 `localStorage` 存储——单页应用便捷选项;注意 XSS 风险。 */
279
- localStorage: createLocalStorageTokenStorage,
280
342
  /** httpOnly Cookie 存储——浏览器场景推荐方案。 */
281
343
  httpOnlyCookie: createHttpOnlyCookieTokenStorage
282
344
  };
@@ -285,7 +347,8 @@ function createApiClient(contract) {
285
347
  config: null,
286
348
  rawClient: null,
287
349
  tokenManager: void 0,
288
- transport: void 0
350
+ transport: void 0,
351
+ observers: /* @__PURE__ */ new Set()
289
352
  };
290
353
  const auth = {
291
354
  async setTokens(tokens) {
@@ -341,6 +404,7 @@ function createApiClient(contract) {
341
404
  }
342
405
  },
343
406
  async close() {
407
+ state.observers.clear();
344
408
  state.transport?.destroy();
345
409
  state.config = null;
346
410
  state.rawClient = null;
@@ -353,7 +417,13 @@ function createApiClient(contract) {
353
417
  get isInitialized() {
354
418
  return state.rawClient !== null;
355
419
  },
356
- auth
420
+ auth,
421
+ observeRequests(observer) {
422
+ state.observers.add(observer);
423
+ return () => {
424
+ state.observers.delete(observer);
425
+ };
426
+ }
357
427
  };
358
428
  return new Proxy({}, {
359
429
  get(_target, property) {
@@ -390,10 +460,31 @@ function createProcedureProxy(state, path) {
390
460
  return createProcedureProxy(state, [...path, property]);
391
461
  },
392
462
  apply(_t, _thisArg, args) {
393
- return callProcedure(state.rawClient, path, args);
463
+ return callObservedProcedure(state.rawClient, path, args, state.observers);
394
464
  }
395
465
  });
396
466
  }
467
+ async function callObservedProcedure(rawClient, path, args, observers) {
468
+ const record = { requestId: core.context.get().requestId ?? core.id.uuid(), procedure: path.join("."), timestamp: Date.now(), duration: 0, success: false, attempts: 0 };
469
+ const provided = args[1] && typeof args[1] === "object" ? args[1] : {};
470
+ const providedContext = provided.context && typeof provided.context === "object" ? provided.context : {};
471
+ const options = { ...provided, context: { ...providedContext, [REQUEST_OBSERVATION]: record } };
472
+ const result = await callProcedure(rawClient, path, [args[0], options]);
473
+ record.duration = Math.max(0, Date.now() - record.timestamp);
474
+ record.success = !(result && typeof result === "object" && "success" in result && result.success === false);
475
+ if (result && typeof result === "object" && "error" in result && result.error && typeof result.error === "object" && "code" in result.error)
476
+ record.errorCode = String(result.error.code);
477
+ for (const observer of observers ?? []) {
478
+ try {
479
+ const observed = observer(Object.freeze({ ...record }));
480
+ if (observed instanceof Promise)
481
+ void observed.catch(() => {
482
+ });
483
+ } catch {
484
+ }
485
+ }
486
+ return result;
487
+ }
397
488
  async function callProcedure(rawClient, path, args) {
398
489
  if (!rawClient) {
399
490
  return err(HaiApiClientError.NOT_INITIALIZED, apiClientM("apiClient_notInitialized"));
@@ -425,6 +516,8 @@ function buildTokenManager(config, transport) {
425
516
  const refreshFetch = async (input, init) => {
426
517
  const configured = typeof config.headers === "function" ? await config.headers() : config.headers ?? {};
427
518
  const headers = new Headers(configured);
519
+ if (!headers.has("accept-language"))
520
+ headers.set("accept-language", core.i18n.getGlobalLocale());
428
521
  headers.set("x-request-client", config.clientName ?? DEFAULT_CLIENT_NAME);
429
522
  new Headers(init?.headers).forEach((value, name) => headers.set(name, value));
430
523
  return fetchFn(input, { ...init, headers });
@@ -442,27 +535,50 @@ function buildTransportSession(config) {
442
535
  const baseUrl = config.baseUrl.replace(TRAILING_SLASHES_REGEX, "");
443
536
  const path = config.transport.keyExchangePath ?? "/_hai/key-exchange";
444
537
  const keyExchangeUrl = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
445
- return config.transport.crypto.transport.createClient({ keyExchangeUrl, fetch: baseFetch });
538
+ return config.transport.crypto.transport.createClient({ keyExchangeUrl, fetch: baseFetch, maxPlaintextBytes: config.transport.maxPlaintextBytes });
446
539
  }
447
540
  function buildRawClient(contract, config, tokenManager, transport) {
448
541
  const link = new OpenAPILink(contract, {
449
542
  url: config.baseUrl,
450
- headers: async () => buildHeaders(config, tokenManager),
451
- fetch: async (request, init) => fetchWithRefresh(request, init, config, tokenManager, transport)
543
+ headers: async (options) => buildHeaders(config, tokenManager, getObservation(options.context)?.requestId),
544
+ fetch: async (request, init, options) => {
545
+ const record = getObservation(options.context);
546
+ if (record) {
547
+ record.method = request.method;
548
+ const url = new URL(request.url);
549
+ url.search = "";
550
+ url.hash = "";
551
+ record.url = core.sanitize.sanitizeUrl(url.toString());
552
+ }
553
+ const response = await fetchWithRefresh(request, init, config, tokenManager, transport, record);
554
+ if (record)
555
+ record.status = response.status;
556
+ return response;
557
+ }
452
558
  });
453
559
  return createORPCClient(link);
454
560
  }
455
- async function buildHeaders(config, tokenManager) {
561
+ function getObservation(context) {
562
+ const record = context[REQUEST_OBSERVATION];
563
+ return record && typeof record === "object" && "requestId" in record ? record : void 0;
564
+ }
565
+ async function buildHeaders(config, tokenManager, requestId) {
456
566
  const configured = typeof config.headers === "function" ? await config.headers() : config.headers ?? {};
567
+ const headers = new Headers(configured);
568
+ if (!headers.has("accept-language"))
569
+ headers.set("accept-language", core.i18n.getGlobalLocale());
457
570
  const accessToken = await tokenManager?.storage.getAccessToken();
458
571
  return {
459
- ...configured,
572
+ ...Object.fromEntries(headers),
460
573
  "x-request-client": config.clientName ?? DEFAULT_CLIENT_NAME,
574
+ "x-request-id": requestId ?? core.context.get().requestId ?? core.id.uuid(),
461
575
  ...accessToken ? { authorization: `Bearer ${accessToken}` } : {}
462
576
  };
463
577
  }
464
- async function fetchWithRefresh(request, init, config, tokenManager, transport) {
578
+ async function fetchWithRefresh(request, init, config, tokenManager, transport, observation) {
465
579
  const retryRequest = tokenManager ? request.clone() : void 0;
580
+ if (observation)
581
+ observation.attempts++;
466
582
  const response = await fetchWithTimeout(request, init, config, transport);
467
583
  if (!tokenManager || !retryRequest)
468
584
  return response;
@@ -478,6 +594,8 @@ async function fetchWithRefresh(request, init, config, tokenManager, transport)
478
594
  return response;
479
595
  const headers = new Headers(retryRequest.headers);
480
596
  headers.set("authorization", `Bearer ${tokens.accessToken}`);
597
+ if (observation)
598
+ observation.attempts++;
481
599
  return fetchWithTimeout(new Request(retryRequest, { headers }), init, config, transport);
482
600
  }
483
601
  async function isUnauthorizedResult(response) {
@@ -513,8 +631,18 @@ async function fetchWithTimeout(request, init, config, transport) {
513
631
  }
514
632
  }
515
633
  function mapClientError(error) {
634
+ const time = error instanceof TransportTimeError ? error : error instanceof Error && error.cause instanceof TransportTimeError ? error.cause : void 0;
635
+ if (time)
636
+ return err(HaiApiClientError.VALIDATION_FAILED, time.message, { transportCode: time.code });
637
+ const capacity = error instanceof TransportBodyTooLargeError ? error : error instanceof Error && error.cause instanceof TransportBodyTooLargeError ? error.cause : void 0;
638
+ if (capacity)
639
+ return err(HaiApiClientError.PAYLOAD_TOO_LARGE, capacity.message, { direction: capacity.direction, stage: capacity.stage, limitBytes: capacity.limitBytes, actualBytes: capacity.actualBytes });
516
640
  if (error instanceof ORPCError) {
517
641
  const detail = { orpcCode: error.code, status: error.status, data: error.data };
642
+ if (error.status === 413)
643
+ return err(HaiApiClientError.PAYLOAD_TOO_LARGE, error.message, detail);
644
+ if (error.status === 400 || error.status === 422)
645
+ return err(HaiApiClientError.VALIDATION_FAILED, error.message, detail);
518
646
  if (error.status === 401)
519
647
  return err(HaiApiClientError.UNAUTHORIZED, error.message, detail);
520
648
  if (error.status === 403)