@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/CHANGELOG.md +18 -0
- package/README.md +232 -2
- package/dist/apps.d.ts +34 -0
- package/dist/apps.d.ts.map +1 -0
- package/dist/apps.js +30 -0
- package/dist/apps.js.map +1 -0
- package/dist/client.d.ts +31 -11
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +316 -107
- package/dist/client.js.map +1 -1
- package/dist/components.d.ts +43 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +37 -0
- package/dist/components.js.map +1 -0
- package/dist/extensions.d.ts +17 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/extensions.js +4 -0
- package/dist/extensions.js.map +1 -0
- package/dist/frontend.d.ts +40 -0
- package/dist/frontend.d.ts.map +1 -0
- package/dist/frontend.js +120 -0
- package/dist/frontend.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +23 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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 (
|
|
356
|
-
|
|
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
|
-
|
|
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",
|
|
496
|
+
es.addEventListener("open", () => { if (!closed)
|
|
497
|
+
opts.onOpen?.(); });
|
|
389
498
|
}
|
|
390
499
|
if (opts?.onError) {
|
|
391
|
-
es.addEventListener("error",
|
|
500
|
+
es.addEventListener("error", (error) => { if (!closed)
|
|
501
|
+
opts.onError?.(error); });
|
|
392
502
|
}
|
|
393
|
-
return { 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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
631
|
+
payload = JSON.parse(data);
|
|
460
632
|
}
|
|
461
633
|
catch {
|
|
462
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
-
|
|
499
|
-
|
|
699
|
+
break;
|
|
700
|
+
reportError(error);
|
|
500
701
|
}
|
|
501
702
|
finally {
|
|
502
|
-
reader
|
|
503
|
-
|
|
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 = () =>
|
|
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 (
|
|
586
|
-
|
|
587
|
-
if (
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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) {
|