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