@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/CHANGELOG.md +11 -0
- package/README.md +180 -1
- 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 +28 -11
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +313 -107
- package/dist/client.js.map +1 -1
- package/dist/components.d.ts +42 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +36 -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/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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
|
@@ -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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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 (
|
|
356
|
-
|
|
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
|
-
|
|
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",
|
|
493
|
+
es.addEventListener("open", () => { if (!closed)
|
|
494
|
+
opts.onOpen?.(); });
|
|
389
495
|
}
|
|
390
496
|
if (opts?.onError) {
|
|
391
|
-
es.addEventListener("error",
|
|
497
|
+
es.addEventListener("error", (error) => { if (!closed)
|
|
498
|
+
opts.onError?.(error); });
|
|
392
499
|
}
|
|
393
|
-
return { 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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
628
|
+
payload = JSON.parse(data);
|
|
460
629
|
}
|
|
461
630
|
catch {
|
|
462
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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);
|
|
681
|
+
skipLF = false;
|
|
682
|
+
if (char === "\r" || char === "\n") {
|
|
683
|
+
line(buffer);
|
|
684
|
+
buffer = "";
|
|
685
|
+
skipLF = char === "\r";
|
|
489
686
|
}
|
|
490
|
-
|
|
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
|
-
|
|
499
|
-
|
|
696
|
+
break;
|
|
697
|
+
reportError(error);
|
|
500
698
|
}
|
|
501
699
|
finally {
|
|
502
|
-
reader
|
|
503
|
-
|
|
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 = () =>
|
|
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 (
|
|
586
|
-
|
|
587
|
-
if (
|
|
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
|
-
|
|
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());
|
|
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) {
|