@apteva/web-sdk 0.5.4 → 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
@@ -3,27 +3,81 @@ const DEFAULT_TIMEOUT_MS = 30_000;
3
3
  export class AptevaClient {
4
4
  baseURL;
5
5
  apiKey;
6
+ accessToken;
6
7
  projectId;
7
8
  fetchImpl;
8
9
  onUnauthorized;
9
10
  timeoutMs;
11
+ refreshTokenHook;
12
+ credentialRevision = 0;
13
+ pendingTokenRefresh;
10
14
  pendingChatResolutions = new Map();
11
15
  constructor(opts) {
12
16
  this.baseURL = opts.baseURL.replace(/\/+$/, "");
13
17
  this.apiKey = opts.apiKey;
18
+ this.accessToken = opts.accessToken;
14
19
  this.projectId = opts.projectId?.trim() || undefined;
15
20
  this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
16
21
  this.onUnauthorized = opts.onUnauthorized;
17
22
  this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
23
+ this.refreshTokenHook = opts.refreshAccessToken;
18
24
  }
19
- // Swap the API key at runtime (e.g. after the user mints a fresh
20
- // one in settings). Pass undefined to fall back to cookie auth.
25
+ // Swap the API key at runtime. An accessToken, when configured, continues
26
+ // to take precedence. Pass undefined to fall back to access-token/cookie auth.
21
27
  setApiKey(key) {
22
28
  this.apiKey = key;
29
+ this.credentialRevision++;
23
30
  }
24
31
  getApiKey() {
25
32
  return this.apiKey;
26
33
  }
34
+ // Swap an application-user bearer token after login or refresh. Tokens are
35
+ // opaque to the SDK: no issuer-specific prefix or response type is required.
36
+ // Pass undefined to fall back to apiKey, then cookie auth.
37
+ setAccessToken(token) {
38
+ this.accessToken = token;
39
+ this.credentialRevision++;
40
+ }
41
+ getAccessToken() {
42
+ return this.accessToken;
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
+ }
27
81
  // Auth surface. Maps 1:1 onto /api/auth/*. login() sets a cookie
28
82
  // on the server side that subsequent requests pick up automatically
29
83
  // when credentials: "include" is sent.
@@ -42,9 +96,10 @@ export class AptevaClient {
42
96
  list: () => this.get("/api/projects"),
43
97
  get: (id) => this.get(`/api/projects/${encodeURIComponent(id)}`),
44
98
  };
45
- // Trusted-backend surface for minting a short-lived, subject-bound browser
46
- // credential. Never configure this caller with an sk- key in production
47
- // browser code; call it from the customer's own backend.
99
+ // Legacy trusted-backend surface for minting a short-lived, subject-bound
100
+ // browser credential. New integrations should receive a generic token from
101
+ // their identity flow and configure it through accessToken instead.
102
+ /** @deprecated Prefer AptevaClient({ accessToken }). */
48
103
  delegatedUsers = {
49
104
  create: (input) => this.post("/api/auth/delegated-users", {
50
105
  project_id: input.projectId,
@@ -254,30 +309,72 @@ export class AptevaClient {
254
309
  // MCP tool surface (/api/apps/<name>/mcp). T is the default return
255
310
  // type for typed route helpers — defaults to unknown so callers
256
311
  // either pass a per-call generic or stick to `unknown`.
257
- 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
+ }
258
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
+ };
259
338
  return {
260
339
  name,
261
- get: (path) => this.get(this.appPath(base + path)),
262
- post: (path, body) => this.post(this.appPath(base + path), body),
263
- put: (path, body) => this.put(this.appPath(base + path), body),
264
- patch: (path, body) => this.patch(this.appPath(base + path), body),
265
- del: (path) => this.del(this.appPath(base + path)),
266
- 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),
267
355
  mcpURL: (queryParams) => {
268
- const u = new URL(this.baseURL + this.appPath(base + "/mcp"), "http://_");
269
- if (queryParams) {
270
- for (const [k, v] of Object.entries(queryParams)) {
271
- u.searchParams.set(k, v);
272
- }
273
- }
274
- 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}`);
275
360
  },
276
361
  };
277
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
+ };
278
375
  // Lower-level direct MCP call. Most callers should use
279
376
  // client.app("name").tool("name", args) which routes through this.
280
- async callTool(appName, toolName, args) {
377
+ async callTool(appName, toolName, args, init) {
281
378
  const path = this.appPath(`/api/apps/${encodeURIComponent(appName)}/mcp`);
282
379
  const body = {
283
380
  jsonrpc: "2.0",
@@ -285,7 +382,7 @@ export class AptevaClient {
285
382
  method: "tools/call",
286
383
  params: { name: toolName, arguments: args },
287
384
  };
288
- const env = await this.post(path, body);
385
+ const env = await this.post(path, body, init);
289
386
  return unwrapMCP(env);
290
387
  }
291
388
  // --- raw HTTP helpers (exposed so consumers can hit endpoints the
@@ -317,7 +414,7 @@ export class AptevaClient {
317
414
  url.searchParams.set(k, String(v));
318
415
  }
319
416
  }
320
- if (this.apiKey && !this.apiKey.startsWith("uk_") && !url.searchParams.has("api_key")) {
417
+ if (this.apiKey && !this.isAccessTokenCredential() && !url.searchParams.has("api_key")) {
321
418
  url.searchParams.set("api_key", this.apiKey);
322
419
  }
323
420
  return this.baseURL ? this.baseURL + url.pathname + url.search : url.pathname + url.search;
@@ -334,11 +431,30 @@ export class AptevaClient {
334
431
  // Node note: needs a global EventSource (Node 22+, browsers, Deno, Bun)
335
432
  // or an injected one via opts.EventSource.
336
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
+ }
337
448
  // A supplied EventSource is an explicit transport choice for cookie/sk-
338
- // clients (and is useful for tests/polyfills). Delegated uk- credentials
339
- // always use fetch because they must stay in the Authorization header.
340
- if (this.apiKey && (this.apiKey.startsWith("uk_") || !opts?.EventSource)) {
341
- return this.subscribeWithFetch(path, params, onEvent, opts);
449
+ // clients (and is useful for tests/polyfills). Access tokens always use
450
+ // fetch because they must stay in the Authorization header. Prefix-based
451
+ // detection remains only as a compatibility fallback for legacy callers
452
+ // that passed a delegated token through apiKey.
453
+ const bearerToken = this.bearerToken();
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);
342
458
  }
343
459
  const Ctor = opts?.EventSource ??
344
460
  globalThis.EventSource;
@@ -347,6 +463,7 @@ export class AptevaClient {
347
463
  }
348
464
  const url = this.sseURL(path, params);
349
465
  const es = new Ctor(url, { withCredentials: true });
466
+ let closed = false;
350
467
  // Default event name "message" handles unnamed (default) frames.
351
468
  // Servers that emit named events (`event: foo\n`) need their
352
469
  // names listed in opts.eventTypes — EventSource won't deliver
@@ -354,6 +471,8 @@ export class AptevaClient {
354
471
  // example sends StreamFrame as `event: stream`.
355
472
  const eventTypes = opts?.eventTypes ?? ["message"];
356
473
  const handle = (ev) => {
474
+ if (closed)
475
+ return;
357
476
  const data = ev?.data;
358
477
  if (typeof data !== "string" || data === "")
359
478
  return;
@@ -364,18 +483,26 @@ export class AptevaClient {
364
483
  catch {
365
484
  return; // drop malformed frame
366
485
  }
367
- onEvent(parsed);
486
+ const event = ev;
487
+ onEvent(parsed, { event: event.type || "message", id: event.lastEventId || undefined });
368
488
  };
369
489
  for (const name of eventTypes) {
370
490
  es.addEventListener(name, handle);
371
491
  }
372
492
  if (opts?.onOpen) {
373
- es.addEventListener("open", opts.onOpen);
493
+ es.addEventListener("open", () => { if (!closed)
494
+ opts.onOpen?.(); });
374
495
  }
375
496
  if (opts?.onError) {
376
- es.addEventListener("error", opts.onError);
497
+ es.addEventListener("error", (error) => { if (!closed)
498
+ opts.onError?.(error); });
377
499
  }
378
- return { close: () => es.close() };
500
+ return this.bindAbortSignal({ close: () => {
501
+ if (closed)
502
+ return;
503
+ closed = true;
504
+ es.close();
505
+ } }, opts?.signal);
379
506
  }
380
507
  subscribeWithFetch(path, params, onEvent, opts) {
381
508
  let closed = false;
@@ -383,10 +510,31 @@ export class AptevaClient {
383
510
  let reader;
384
511
  let reconnectTimer;
385
512
  let wakeReconnect;
513
+ let lastEventId = opts?.lastEventId ?? "";
514
+ let renewedSinceOpen = false;
515
+ const delivered = new Set();
386
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
+ };
387
535
  const waitToReconnect = () => new Promise((resolve) => {
388
536
  wakeReconnect = resolve;
389
- reconnectTimer = setTimeout(resolve, 1_000);
537
+ reconnectTimer = setTimeout(resolve, opts?.reconnectDelayMs ?? 1_000);
390
538
  }).finally(() => {
391
539
  reconnectTimer = undefined;
392
540
  wakeReconnect = undefined;
@@ -394,84 +542,150 @@ export class AptevaClient {
394
542
  const run = async () => {
395
543
  while (!closed) {
396
544
  controller = new AbortController();
545
+ const revision = this.credentialRevision;
397
546
  try {
398
547
  const url = new URL(this.baseURL + path, "http://_");
399
- if (params) {
400
- for (const [key, value] of Object.entries(params)) {
401
- if (value !== undefined && value !== "") {
402
- url.searchParams.set(key, String(value));
403
- }
404
- }
548
+ for (const [key, value] of Object.entries(params ?? {})) {
549
+ if (value !== undefined && value !== "")
550
+ url.searchParams.set(key, String(value));
405
551
  }
406
- const requestURL = this.baseURL
407
- ? this.baseURL + url.pathname + url.search
408
- : url.pathname + url.search;
552
+ if (opts?.cursorParam && lastEventId)
553
+ url.searchParams.set(opts.cursorParam, lastEventId);
409
554
  const headers = new Headers({ Accept: "text/event-stream" });
410
- if (this.apiKey)
411
- headers.set("Authorization", `Bearer ${this.apiKey}`);
412
- const response = await this.fetchImpl(requestURL, {
413
- method: "GET",
414
- headers,
415
- credentials: this.apiKey?.startsWith("uk_") ? "omit" : "include",
555
+ const bearerToken = this.bearerToken();
556
+ if (bearerToken)
557
+ headers.set("Authorization", `Bearer ${bearerToken}`);
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,
562
+ credentials: this.isAccessTokenCredential() ? "omit" : "include",
416
563
  signal: controller.signal,
417
564
  });
565
+ if (closed) {
566
+ await response.body?.cancel();
567
+ break;
568
+ }
418
569
  if (!response.ok) {
419
- 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;
420
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
+ }
421
589
  throw new AptevaError(response.status, `SSE connection failed (${response.status})`);
422
590
  }
591
+ if (response.status === 204) {
592
+ await response.body?.cancel();
593
+ close();
594
+ break;
595
+ }
423
596
  if (!response.body)
424
597
  throw new AptevaError(0, "SSE response has no body");
425
- opts?.onOpen?.();
598
+ renewedSinceOpen = false;
426
599
  reader = response.body.getReader();
600
+ try {
601
+ opts?.onOpen?.();
602
+ }
603
+ catch (error) {
604
+ reportError(error);
605
+ close();
606
+ break;
607
+ }
427
608
  const decoder = new TextDecoder();
428
609
  let buffer = "";
610
+ let skipLF = false;
429
611
  let eventName = "message";
612
+ let frameId;
430
613
  let dataLines = [];
431
614
  const dispatch = () => {
432
- if (dataLines.length === 0) {
433
- eventName = "message";
434
- return;
435
- }
615
+ const name = eventName;
616
+ const id = frameId;
436
617
  const data = dataLines.join("\n");
618
+ const hasData = dataLines.length > 0;
437
619
  dataLines = [];
438
- const name = eventName;
439
620
  eventName = "message";
440
- if (!eventTypes.has(name))
621
+ frameId = undefined;
622
+ if (id !== undefined)
623
+ lastEventId = id;
624
+ if (!hasData || !eventTypes.has(name) || closed)
441
625
  return;
626
+ let payload;
442
627
  try {
443
- onEvent(JSON.parse(data));
628
+ payload = JSON.parse(data);
444
629
  }
445
630
  catch {
446
- // One malformed frame must not end a best-effort stream.
631
+ return;
447
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();
648
+ }
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;
448
668
  };
449
669
  while (!closed) {
450
670
  const chunk = await reader.read();
451
- if (chunk.done)
671
+ if (chunk.done || closed)
452
672
  break;
453
- buffer += decoder.decode(chunk.value, { stream: true });
454
- let newline = buffer.indexOf("\n");
455
- while (newline >= 0) {
456
- let line = buffer.slice(0, newline);
457
- buffer = buffer.slice(newline + 1);
458
- if (line.endsWith("\r"))
459
- line = line.slice(0, -1);
460
- if (line === "") {
461
- 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;
462
680
  }
463
- else if (!line.startsWith(":")) {
464
- const separator = line.indexOf(":");
465
- const field = separator >= 0 ? line.slice(0, separator) : line;
466
- let value = separator >= 0 ? line.slice(separator + 1) : "";
467
- if (value.startsWith(" "))
468
- value = value.slice(1);
469
- if (field === "event")
470
- eventName = value || "message";
471
- if (field === "data")
472
- dataLines.push(value);
681
+ skipLF = false;
682
+ if (char === "\r" || char === "\n") {
683
+ line(buffer);
684
+ buffer = "";
685
+ skipLF = char === "\r";
473
686
  }
474
- newline = buffer.indexOf("\n");
687
+ else
688
+ buffer += char;
475
689
  }
476
690
  }
477
691
  if (!closed)
@@ -479,12 +693,18 @@ export class AptevaClient {
479
693
  }
480
694
  catch (error) {
481
695
  if (closed)
482
- return;
483
- opts?.onError?.(error);
696
+ break;
697
+ reportError(error);
484
698
  }
485
699
  finally {
486
- reader?.releaseLock();
487
- reader = undefined;
700
+ if (reader) {
701
+ try {
702
+ await reader.cancel();
703
+ }
704
+ catch { /* network already closed */ }
705
+ reader.releaseLock();
706
+ reader = undefined;
707
+ }
488
708
  controller = undefined;
489
709
  }
490
710
  if (!closed)
@@ -492,25 +712,16 @@ export class AptevaClient {
492
712
  }
493
713
  };
494
714
  void run();
495
- return {
496
- close: () => {
497
- if (closed)
498
- return;
499
- closed = true;
500
- const activeReader = reader;
501
- void activeReader?.cancel().catch(() => undefined);
502
- controller?.abort();
503
- if (reconnectTimer !== undefined)
504
- clearTimeout(reconnectTimer);
505
- wakeReconnect?.();
506
- },
507
- };
715
+ return { close };
508
716
  }
509
717
  // --- internals ---
510
718
  bindAbortSignal(handle, signal) {
511
719
  if (!signal)
512
720
  return handle;
513
- const abort = () => handle.close();
721
+ const abort = () => {
722
+ signal.removeEventListener("abort", abort);
723
+ handle.close();
724
+ };
514
725
  if (signal.aborted) {
515
726
  handle.close();
516
727
  return handle;
@@ -532,58 +743,76 @@ export class AptevaClient {
532
743
  }
533
744
  return url.pathname + url.search + url.hash;
534
745
  }
746
+ bearerToken() {
747
+ return this.accessToken || this.apiKey;
748
+ }
749
+ isAccessTokenCredential() {
750
+ return Boolean(this.accessToken) || Boolean(this.apiKey?.startsWith("uk_"));
751
+ }
535
752
  async request(method, path, body, init) {
536
753
  const url = this.baseURL + path;
754
+ const revision = this.credentialRevision;
537
755
  const headers = new Headers(init?.headers);
538
756
  if (!headers.has("Accept"))
539
757
  headers.set("Accept", "application/json");
540
758
  if (body !== undefined && !headers.has("Content-Type")) {
541
759
  headers.set("Content-Type", "application/json");
542
760
  }
543
- if (this.apiKey && !headers.has("Authorization")) {
544
- headers.set("Authorization", `Bearer ${this.apiKey}`);
761
+ const bearerToken = this.bearerToken();
762
+ if (bearerToken && !headers.has("Authorization")) {
763
+ headers.set("Authorization", `Bearer ${bearerToken}`);
545
764
  }
546
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 });
547
771
  const timer = ac
548
772
  ? setTimeout(() => ac.abort(), this.timeoutMs)
549
773
  : undefined;
550
- let res;
551
774
  try {
552
- res = await this.fetchImpl(url, {
775
+ const res = await this.fetchImpl(url, {
553
776
  method,
554
- credentials: this.apiKey?.startsWith("uk_") ? "omit" : "include",
777
+ credentials: this.isAccessTokenCredential() ? "omit" : "include",
555
778
  ...init,
556
779
  headers,
557
780
  body: body === undefined ? init?.body : JSON.stringify(body),
558
781
  signal: ac?.signal ?? init?.signal,
559
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());
560
801
  }
561
802
  catch (err) {
562
- if (timer)
563
- clearTimeout(timer);
564
- 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)
565
808
  throw new AptevaError(0, `request timeout after ${this.timeoutMs}ms`);
566
- }
567
809
  throw new AptevaError(0, err instanceof Error ? err.message : String(err));
568
810
  }
569
- if (timer)
570
- clearTimeout(timer);
571
- if (res.status === 401) {
572
- this.onUnauthorized?.();
573
- const text = await readBody(res);
574
- throw new AptevaError(401, text || "unauthorized");
575
- }
576
- if (!res.ok) {
577
- const text = await readBody(res);
578
- throw new AptevaError(res.status, text || res.statusText);
579
- }
580
- const ct = res.headers.get("Content-Type") ?? "";
581
- if (res.status === 204)
582
- return undefined;
583
- if (ct.includes("application/json")) {
584
- return (await res.json());
811
+ finally {
812
+ if (timer)
813
+ clearTimeout(timer);
814
+ init?.signal?.removeEventListener("abort", abort);
585
815
  }
586
- return (await res.text());
587
816
  }
588
817
  }
589
818
  async function readBody(res) {