@apteva/web-sdk 0.5.5 → 0.7.0

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/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { loadAppFrontend } from "./frontend.js";
1
2
  import { AptevaError } from "./errors.js";
2
3
  const DEFAULT_TIMEOUT_MS = 30_000;
3
4
  export class AptevaClient {
@@ -8,6 +9,9 @@ export class AptevaClient {
8
9
  fetchImpl;
9
10
  onUnauthorized;
10
11
  timeoutMs;
12
+ refreshTokenHook;
13
+ credentialRevision = 0;
14
+ pendingTokenRefresh;
11
15
  pendingChatResolutions = new Map();
12
16
  constructor(opts) {
13
17
  this.baseURL = opts.baseURL.replace(/\/+$/, "");
@@ -17,11 +21,13 @@ export class AptevaClient {
17
21
  this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
18
22
  this.onUnauthorized = opts.onUnauthorized;
19
23
  this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
24
+ this.refreshTokenHook = opts.refreshAccessToken;
20
25
  }
21
26
  // Swap the API key at runtime. An accessToken, when configured, continues
22
27
  // to take precedence. Pass undefined to fall back to access-token/cookie auth.
23
28
  setApiKey(key) {
24
29
  this.apiKey = key;
30
+ this.credentialRevision++;
25
31
  }
26
32
  getApiKey() {
27
33
  return this.apiKey;
@@ -31,10 +37,48 @@ export class AptevaClient {
31
37
  // Pass undefined to fall back to apiKey, then cookie auth.
32
38
  setAccessToken(token) {
33
39
  this.accessToken = token;
40
+ this.credentialRevision++;
34
41
  }
35
42
  getAccessToken() {
36
43
  return this.accessToken;
37
44
  }
45
+ /** Refresh once for concurrent callers. Undefined leaves credentials unchanged. */
46
+ async refreshAccessToken() {
47
+ if (!this.refreshTokenHook)
48
+ return false;
49
+ if (this.pendingTokenRefresh)
50
+ return this.pendingTokenRefresh;
51
+ const revision = this.credentialRevision;
52
+ const refresh = Promise.resolve().then(() => this.refreshTokenHook()).then((token) => {
53
+ // A login/logout/token replacement while refreshing always wins.
54
+ if (this.credentialRevision !== revision)
55
+ return false;
56
+ if (!token || token === this.accessToken)
57
+ return false;
58
+ this.setAccessToken(token);
59
+ return true;
60
+ });
61
+ this.pendingTokenRefresh = refresh;
62
+ try {
63
+ return await refresh;
64
+ }
65
+ finally {
66
+ if (this.pendingTokenRefresh === refresh)
67
+ this.pendingTokenRefresh = undefined;
68
+ }
69
+ }
70
+ async renewAfterUnauthorized(revision) {
71
+ if (revision !== this.credentialRevision)
72
+ return Boolean(this.bearerToken());
73
+ try {
74
+ return await this.refreshAccessToken();
75
+ }
76
+ catch {
77
+ // Preserve the original 401; callers can observe refresh errors by
78
+ // invoking refreshAccessToken() directly or inside their own hook.
79
+ return false;
80
+ }
81
+ }
38
82
  // Auth surface. Maps 1:1 onto /api/auth/*. login() sets a cookie
39
83
  // on the server side that subsequent requests pick up automatically
40
84
  // when credentials: "include" is sent.
@@ -266,30 +310,74 @@ export class AptevaClient {
266
310
  // MCP tool surface (/api/apps/<name>/mcp). T is the default return
267
311
  // type for typed route helpers — defaults to unknown so callers
268
312
  // either pass a per-call generic or stick to `unknown`.
269
- app(name) {
313
+ app(name, scope = {}) {
314
+ const explicitProjectId = scope.projectId?.trim();
315
+ const projectId = explicitProjectId || this.projectId;
316
+ const installId = scope.installId;
317
+ if (installId !== undefined && (!Number.isSafeInteger(installId) || installId <= 0)) {
318
+ throw new AptevaError(0, "installId must be a positive safe integer");
319
+ }
270
320
  const base = `/api/apps/${encodeURIComponent(name)}`;
321
+ const scopedPath = (path) => {
322
+ if (!path.startsWith("/") || path.startsWith("//")) {
323
+ throw new AptevaError(0, "app paths must start with a single slash");
324
+ }
325
+ const url = new URL(base + path, "http://_");
326
+ if (!url.pathname.startsWith(base + "/")) {
327
+ throw new AptevaError(0, "app path must stay within the app");
328
+ }
329
+ // An explicit handle scope wins over per-request query parameters.
330
+ // Preserve legacy query overrides when only the client default is set.
331
+ if (explicitProjectId)
332
+ url.searchParams.set("project_id", projectId);
333
+ else if (projectId && !url.searchParams.has("project_id"))
334
+ url.searchParams.set("project_id", projectId);
335
+ if (installId !== undefined)
336
+ url.searchParams.set("install_id", String(installId));
337
+ return url.pathname + url.search;
338
+ };
271
339
  return {
272
340
  name,
273
- get: (path) => this.get(this.appPath(base + path)),
274
- post: (path, body) => this.post(this.appPath(base + path), body),
275
- put: (path, body) => this.put(this.appPath(base + path), body),
276
- patch: (path, body) => this.patch(this.appPath(base + path), body),
277
- del: (path) => this.del(this.appPath(base + path)),
278
- tool: (toolName, args = {}) => this.callTool(name, toolName, args),
341
+ projectId,
342
+ installId,
343
+ get: (path, init) => this.get(scopedPath(path), init),
344
+ post: (path, body, init) => this.post(scopedPath(path), body, init),
345
+ put: (path, body, init) => this.put(scopedPath(path), body, init),
346
+ patch: (path, body, init) => this.patch(scopedPath(path), body, init),
347
+ del: (path, init) => this.del(scopedPath(path), init),
348
+ tool: async (toolName, args = {}, init) => {
349
+ const env = await this.post(scopedPath("/mcp"), {
350
+ jsonrpc: "2.0", id: 1, method: "tools/call",
351
+ params: { name: toolName, arguments: args },
352
+ }, init);
353
+ return unwrapMCP(env);
354
+ },
355
+ subscribe: (path, onEvent, opts) => this.subscribe(scopedPath(path), undefined, onEvent, opts),
279
356
  mcpURL: (queryParams) => {
280
- const u = new URL(this.baseURL + this.appPath(base + "/mcp"), "http://_");
281
- if (queryParams) {
282
- for (const [k, v] of Object.entries(queryParams)) {
283
- u.searchParams.set(k, v);
284
- }
285
- }
286
- return this.baseURL ? this.baseURL + u.pathname + u.search : u.pathname + u.search;
357
+ const url = new URL(scopedPath("/mcp"), "http://_");
358
+ for (const [key, value] of Object.entries(queryParams ?? {}))
359
+ url.searchParams.set(key, value);
360
+ return this.baseURL + scopedPath(`/mcp${url.search}`);
287
361
  },
288
362
  };
289
363
  }
364
+ /** Create an extension instance. Retain it in the host; use() does not cache. */
365
+ use(extension, scope) {
366
+ return extension.create({ app: this.app(extension.app, scope) });
367
+ }
368
+ apps = {
369
+ /** Load an installed app’s bundled client and optional React UI. */
370
+ load: (name, options) => loadAppFrontend(this.app(name, options), options),
371
+ /** Requires the platform's existing app-list permission. No auth bypass. */
372
+ list: (scope = {}, init) => {
373
+ const projectId = scope.projectId ?? this.projectId;
374
+ const query = projectId ? `?project_id=${encodeURIComponent(projectId)}` : "";
375
+ return this.get(`/api/apps${query}`, init);
376
+ },
377
+ };
290
378
  // Lower-level direct MCP call. Most callers should use
291
379
  // client.app("name").tool("name", args) which routes through this.
292
- async callTool(appName, toolName, args) {
380
+ async callTool(appName, toolName, args, init) {
293
381
  const path = this.appPath(`/api/apps/${encodeURIComponent(appName)}/mcp`);
294
382
  const body = {
295
383
  jsonrpc: "2.0",
@@ -297,7 +385,7 @@ export class AptevaClient {
297
385
  method: "tools/call",
298
386
  params: { name: toolName, arguments: args },
299
387
  };
300
- const env = await this.post(path, body);
388
+ const env = await this.post(path, body, init);
301
389
  return unwrapMCP(env);
302
390
  }
303
391
  // --- raw HTTP helpers (exposed so consumers can hit endpoints the
@@ -346,14 +434,30 @@ export class AptevaClient {
346
434
  // Node note: needs a global EventSource (Node 22+, browsers, Deno, Bun)
347
435
  // or an injected one via opts.EventSource.
348
436
  subscribe(path, params, onEvent, opts) {
437
+ if (opts?.signal?.aborted)
438
+ return { close() { } };
439
+ if (opts?.reconnectDelayMs !== undefined && (!Number.isFinite(opts.reconnectDelayMs) || opts.reconnectDelayMs < 0)) {
440
+ throw new AptevaError(0, "reconnectDelayMs must be a non-negative finite number");
441
+ }
442
+ if (opts?.deduplicationWindow !== undefined && (!Number.isSafeInteger(opts.deduplicationWindow) || opts.deduplicationWindow < 1)) {
443
+ throw new AptevaError(0, "deduplicationWindow must be a positive safe integer");
444
+ }
445
+ if (opts?.cursorParam && ["project_id", "install_id", "api_key", "access_token"].includes(opts.cursorParam)) {
446
+ throw new AptevaError(0, "cursorParam must not override routing or credentials");
447
+ }
448
+ if (opts?.lastEventId && /[\r\n\0]/.test(opts.lastEventId)) {
449
+ throw new AptevaError(0, "lastEventId must not contain CR, LF or NUL");
450
+ }
349
451
  // A supplied EventSource is an explicit transport choice for cookie/sk-
350
452
  // clients (and is useful for tests/polyfills). Access tokens always use
351
453
  // fetch because they must stay in the Authorization header. Prefix-based
352
454
  // detection remains only as a compatibility fallback for legacy callers
353
455
  // that passed a delegated token through apiKey.
354
456
  const bearerToken = this.bearerToken();
355
- if (bearerToken && (this.isAccessTokenCredential() || !opts?.EventSource)) {
356
- return this.subscribeWithFetch(path, params, onEvent, opts);
457
+ if (opts?.transport === "fetch" || opts?.lastEventId !== undefined || opts?.cursorParam ||
458
+ opts?.deduplicate || opts?.reconnectDelayMs !== undefined ||
459
+ (bearerToken && (this.isAccessTokenCredential() || !opts?.EventSource))) {
460
+ return this.bindAbortSignal(this.subscribeWithFetch(path, params, onEvent, opts), opts?.signal);
357
461
  }
358
462
  const Ctor = opts?.EventSource ??
359
463
  globalThis.EventSource;
@@ -362,6 +466,7 @@ export class AptevaClient {
362
466
  }
363
467
  const url = this.sseURL(path, params);
364
468
  const es = new Ctor(url, { withCredentials: true });
469
+ let closed = false;
365
470
  // Default event name "message" handles unnamed (default) frames.
366
471
  // Servers that emit named events (`event: foo\n`) need their
367
472
  // names listed in opts.eventTypes — EventSource won't deliver
@@ -369,6 +474,8 @@ export class AptevaClient {
369
474
  // example sends StreamFrame as `event: stream`.
370
475
  const eventTypes = opts?.eventTypes ?? ["message"];
371
476
  const handle = (ev) => {
477
+ if (closed)
478
+ return;
372
479
  const data = ev?.data;
373
480
  if (typeof data !== "string" || data === "")
374
481
  return;
@@ -379,18 +486,26 @@ export class AptevaClient {
379
486
  catch {
380
487
  return; // drop malformed frame
381
488
  }
382
- onEvent(parsed);
489
+ const event = ev;
490
+ onEvent(parsed, { event: event.type || "message", id: event.lastEventId || undefined });
383
491
  };
384
492
  for (const name of eventTypes) {
385
493
  es.addEventListener(name, handle);
386
494
  }
387
495
  if (opts?.onOpen) {
388
- es.addEventListener("open", opts.onOpen);
496
+ es.addEventListener("open", () => { if (!closed)
497
+ opts.onOpen?.(); });
389
498
  }
390
499
  if (opts?.onError) {
391
- es.addEventListener("error", opts.onError);
500
+ es.addEventListener("error", (error) => { if (!closed)
501
+ opts.onError?.(error); });
392
502
  }
393
- return { close: () => es.close() };
503
+ return this.bindAbortSignal({ close: () => {
504
+ if (closed)
505
+ return;
506
+ closed = true;
507
+ es.close();
508
+ } }, opts?.signal);
394
509
  }
395
510
  subscribeWithFetch(path, params, onEvent, opts) {
396
511
  let closed = false;
@@ -398,10 +513,31 @@ export class AptevaClient {
398
513
  let reader;
399
514
  let reconnectTimer;
400
515
  let wakeReconnect;
516
+ let lastEventId = opts?.lastEventId ?? "";
517
+ let renewedSinceOpen = false;
518
+ const delivered = new Set();
401
519
  const eventTypes = new Set(opts?.eventTypes ?? ["message"]);
520
+ const close = () => {
521
+ if (closed)
522
+ return;
523
+ closed = true;
524
+ void reader?.cancel().catch(() => undefined);
525
+ controller?.abort();
526
+ if (reconnectTimer !== undefined)
527
+ clearTimeout(reconnectTimer);
528
+ wakeReconnect?.();
529
+ };
530
+ const reportError = (error) => {
531
+ try {
532
+ opts?.onError?.(error);
533
+ }
534
+ catch {
535
+ close();
536
+ }
537
+ };
402
538
  const waitToReconnect = () => new Promise((resolve) => {
403
539
  wakeReconnect = resolve;
404
- reconnectTimer = setTimeout(resolve, 1_000);
540
+ reconnectTimer = setTimeout(resolve, opts?.reconnectDelayMs ?? 1_000);
405
541
  }).finally(() => {
406
542
  reconnectTimer = undefined;
407
543
  wakeReconnect = undefined;
@@ -409,85 +545,150 @@ export class AptevaClient {
409
545
  const run = async () => {
410
546
  while (!closed) {
411
547
  controller = new AbortController();
548
+ const revision = this.credentialRevision;
412
549
  try {
413
550
  const url = new URL(this.baseURL + path, "http://_");
414
- if (params) {
415
- for (const [key, value] of Object.entries(params)) {
416
- if (value !== undefined && value !== "") {
417
- url.searchParams.set(key, String(value));
418
- }
419
- }
551
+ for (const [key, value] of Object.entries(params ?? {})) {
552
+ if (value !== undefined && value !== "")
553
+ url.searchParams.set(key, String(value));
420
554
  }
421
- const requestURL = this.baseURL
422
- ? this.baseURL + url.pathname + url.search
423
- : url.pathname + url.search;
555
+ if (opts?.cursorParam && lastEventId)
556
+ url.searchParams.set(opts.cursorParam, lastEventId);
424
557
  const headers = new Headers({ Accept: "text/event-stream" });
425
558
  const bearerToken = this.bearerToken();
426
559
  if (bearerToken)
427
560
  headers.set("Authorization", `Bearer ${bearerToken}`);
428
- const response = await this.fetchImpl(requestURL, {
429
- method: "GET",
430
- headers,
561
+ if (lastEventId)
562
+ headers.set("Last-Event-ID", lastEventId);
563
+ const response = await this.fetchImpl(this.baseURL + url.pathname + url.search, {
564
+ method: "GET", headers,
431
565
  credentials: this.isAccessTokenCredential() ? "omit" : "include",
432
566
  signal: controller.signal,
433
567
  });
568
+ if (closed) {
569
+ await response.body?.cancel();
570
+ break;
571
+ }
434
572
  if (!response.ok) {
435
- if (response.status === 401)
573
+ await response.body?.cancel();
574
+ if (response.status === 401) {
575
+ const renewed = !renewedSinceOpen && await this.renewAfterUnauthorized(revision);
576
+ renewedSinceOpen = renewed;
577
+ if (closed)
578
+ break;
436
579
  this.onUnauthorized?.();
580
+ reportError(new AptevaError(401, "SSE connection failed (401)"));
581
+ if (!renewed) {
582
+ close();
583
+ break;
584
+ }
585
+ continue;
586
+ }
587
+ if (response.status === 403) {
588
+ reportError(new AptevaError(403, "SSE connection failed (403)"));
589
+ close();
590
+ break;
591
+ }
437
592
  throw new AptevaError(response.status, `SSE connection failed (${response.status})`);
438
593
  }
594
+ if (response.status === 204) {
595
+ await response.body?.cancel();
596
+ close();
597
+ break;
598
+ }
439
599
  if (!response.body)
440
600
  throw new AptevaError(0, "SSE response has no body");
441
- opts?.onOpen?.();
601
+ renewedSinceOpen = false;
442
602
  reader = response.body.getReader();
603
+ try {
604
+ opts?.onOpen?.();
605
+ }
606
+ catch (error) {
607
+ reportError(error);
608
+ close();
609
+ break;
610
+ }
443
611
  const decoder = new TextDecoder();
444
612
  let buffer = "";
613
+ let skipLF = false;
445
614
  let eventName = "message";
615
+ let frameId;
446
616
  let dataLines = [];
447
617
  const dispatch = () => {
448
- if (dataLines.length === 0) {
449
- eventName = "message";
450
- return;
451
- }
618
+ const name = eventName;
619
+ const id = frameId;
452
620
  const data = dataLines.join("\n");
621
+ const hasData = dataLines.length > 0;
453
622
  dataLines = [];
454
- const name = eventName;
455
623
  eventName = "message";
456
- if (!eventTypes.has(name))
624
+ frameId = undefined;
625
+ if (id !== undefined)
626
+ lastEventId = id;
627
+ if (!hasData || !eventTypes.has(name) || closed)
457
628
  return;
629
+ let payload;
458
630
  try {
459
- onEvent(JSON.parse(data));
631
+ payload = JSON.parse(data);
460
632
  }
461
633
  catch {
462
- // One malformed frame must not end a best-effort stream.
634
+ return;
635
+ }
636
+ if (opts?.deduplicate && id && delivered.has(id))
637
+ return;
638
+ // IDs identify individual events only when the app promises that.
639
+ if (opts?.deduplicate && id) {
640
+ delivered.add(id);
641
+ if (delivered.size > (opts.deduplicationWindow ?? 1000)) {
642
+ delivered.delete(delivered.values().next().value);
643
+ }
644
+ }
645
+ try {
646
+ onEvent(payload, { event: name, id: lastEventId || undefined });
647
+ }
648
+ catch (error) {
649
+ reportError(error);
650
+ close();
463
651
  }
464
652
  };
653
+ const line = (value) => {
654
+ if (!value) {
655
+ dispatch();
656
+ return;
657
+ }
658
+ if (value.startsWith(":"))
659
+ return;
660
+ const separator = value.indexOf(":");
661
+ const field = separator < 0 ? value : value.slice(0, separator);
662
+ let content = separator < 0 ? "" : value.slice(separator + 1);
663
+ if (content.startsWith(" "))
664
+ content = content.slice(1);
665
+ if (field === "event")
666
+ eventName = content || "message";
667
+ if (field === "data")
668
+ dataLines.push(content);
669
+ if (field === "id" && !content.includes("\0"))
670
+ frameId = content;
671
+ };
465
672
  while (!closed) {
466
673
  const chunk = await reader.read();
467
- if (chunk.done)
674
+ if (chunk.done || closed)
468
675
  break;
469
- buffer += decoder.decode(chunk.value, { stream: true });
470
- let newline = buffer.indexOf("\n");
471
- while (newline >= 0) {
472
- let line = buffer.slice(0, newline);
473
- buffer = buffer.slice(newline + 1);
474
- if (line.endsWith("\r"))
475
- line = line.slice(0, -1);
476
- if (line === "") {
477
- dispatch();
676
+ // Handles LF, CRLF and CR, including CRLF split across chunks.
677
+ for (const char of decoder.decode(chunk.value, { stream: true })) {
678
+ if (closed)
679
+ break;
680
+ if (skipLF && char === "\n") {
681
+ skipLF = false;
682
+ continue;
478
683
  }
479
- else if (!line.startsWith(":")) {
480
- const separator = line.indexOf(":");
481
- const field = separator >= 0 ? line.slice(0, separator) : line;
482
- let value = separator >= 0 ? line.slice(separator + 1) : "";
483
- if (value.startsWith(" "))
484
- value = value.slice(1);
485
- if (field === "event")
486
- eventName = value || "message";
487
- if (field === "data")
488
- dataLines.push(value);
684
+ skipLF = false;
685
+ if (char === "\r" || char === "\n") {
686
+ line(buffer);
687
+ buffer = "";
688
+ skipLF = char === "\r";
489
689
  }
490
- newline = buffer.indexOf("\n");
690
+ else
691
+ buffer += char;
491
692
  }
492
693
  }
493
694
  if (!closed)
@@ -495,12 +696,18 @@ export class AptevaClient {
495
696
  }
496
697
  catch (error) {
497
698
  if (closed)
498
- return;
499
- opts?.onError?.(error);
699
+ break;
700
+ reportError(error);
500
701
  }
501
702
  finally {
502
- reader?.releaseLock();
503
- reader = undefined;
703
+ if (reader) {
704
+ try {
705
+ await reader.cancel();
706
+ }
707
+ catch { /* network already closed */ }
708
+ reader.releaseLock();
709
+ reader = undefined;
710
+ }
504
711
  controller = undefined;
505
712
  }
506
713
  if (!closed)
@@ -508,25 +715,16 @@ export class AptevaClient {
508
715
  }
509
716
  };
510
717
  void run();
511
- return {
512
- close: () => {
513
- if (closed)
514
- return;
515
- closed = true;
516
- const activeReader = reader;
517
- void activeReader?.cancel().catch(() => undefined);
518
- controller?.abort();
519
- if (reconnectTimer !== undefined)
520
- clearTimeout(reconnectTimer);
521
- wakeReconnect?.();
522
- },
523
- };
718
+ return { close };
524
719
  }
525
720
  // --- internals ---
526
721
  bindAbortSignal(handle, signal) {
527
722
  if (!signal)
528
723
  return handle;
529
- const abort = () => handle.close();
724
+ const abort = () => {
725
+ signal.removeEventListener("abort", abort);
726
+ handle.close();
727
+ };
530
728
  if (signal.aborted) {
531
729
  handle.close();
532
730
  return handle;
@@ -556,6 +754,7 @@ export class AptevaClient {
556
754
  }
557
755
  async request(method, path, body, init) {
558
756
  const url = this.baseURL + path;
757
+ const revision = this.credentialRevision;
559
758
  const headers = new Headers(init?.headers);
560
759
  if (!headers.has("Accept"))
561
760
  headers.set("Accept", "application/json");
@@ -567,12 +766,16 @@ export class AptevaClient {
567
766
  headers.set("Authorization", `Bearer ${bearerToken}`);
568
767
  }
569
768
  const ac = this.timeoutMs > 0 ? new AbortController() : undefined;
769
+ const abort = () => ac?.abort(init?.signal?.reason);
770
+ if (init?.signal?.aborted)
771
+ abort();
772
+ else
773
+ init?.signal?.addEventListener("abort", abort, { once: true });
570
774
  const timer = ac
571
775
  ? setTimeout(() => ac.abort(), this.timeoutMs)
572
776
  : undefined;
573
- let res;
574
777
  try {
575
- res = await this.fetchImpl(url, {
778
+ const res = await this.fetchImpl(url, {
576
779
  method,
577
780
  credentials: this.isAccessTokenCredential() ? "omit" : "include",
578
781
  ...init,
@@ -580,33 +783,39 @@ export class AptevaClient {
580
783
  body: body === undefined ? init?.body : JSON.stringify(body),
581
784
  signal: ac?.signal ?? init?.signal,
582
785
  });
786
+ if (res.status === 401) {
787
+ const text = await readBody(res);
788
+ // Renew for subsequent calls, but never replay an HTTP operation.
789
+ if (!new Headers(init?.headers).has("Authorization"))
790
+ await this.renewAfterUnauthorized(revision);
791
+ this.onUnauthorized?.();
792
+ throw new AptevaError(401, text || "unauthorized");
793
+ }
794
+ if (!res.ok) {
795
+ const text = await readBody(res);
796
+ throw new AptevaError(res.status, text || res.statusText);
797
+ }
798
+ if (res.status === 204)
799
+ return undefined;
800
+ const ct = res.headers.get("Content-Type") ?? "";
801
+ if (ct.includes("application/json"))
802
+ return (await res.json());
803
+ return (await res.text());
583
804
  }
584
805
  catch (err) {
585
- if (timer)
586
- clearTimeout(timer);
587
- if (err instanceof Error && err.name === "AbortError") {
806
+ if (err instanceof AptevaError)
807
+ throw err;
808
+ if (init?.signal?.aborted)
809
+ throw new AptevaError(0, "request aborted");
810
+ if (ac?.signal.aborted)
588
811
  throw new AptevaError(0, `request timeout after ${this.timeoutMs}ms`);
589
- }
590
812
  throw new AptevaError(0, err instanceof Error ? err.message : String(err));
591
813
  }
592
- if (timer)
593
- clearTimeout(timer);
594
- if (res.status === 401) {
595
- this.onUnauthorized?.();
596
- const text = await readBody(res);
597
- throw new AptevaError(401, text || "unauthorized");
598
- }
599
- if (!res.ok) {
600
- const text = await readBody(res);
601
- throw new AptevaError(res.status, text || res.statusText);
602
- }
603
- const ct = res.headers.get("Content-Type") ?? "";
604
- if (res.status === 204)
605
- return undefined;
606
- if (ct.includes("application/json")) {
607
- return (await res.json());
814
+ finally {
815
+ if (timer)
816
+ clearTimeout(timer);
817
+ init?.signal?.removeEventListener("abort", abort);
608
818
  }
609
- return (await res.text());
610
819
  }
611
820
  }
612
821
  async function readBody(res) {