@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/CHANGELOG.md +23 -0
- package/README.md +224 -33
- 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 +34 -11
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +350 -121
- 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 +28 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
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
|
|
20
|
-
//
|
|
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
|
-
//
|
|
46
|
-
// credential.
|
|
47
|
-
//
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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.
|
|
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).
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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",
|
|
493
|
+
es.addEventListener("open", () => { if (!closed)
|
|
494
|
+
opts.onOpen?.(); });
|
|
374
495
|
}
|
|
375
496
|
if (opts?.onError) {
|
|
376
|
-
es.addEventListener("error",
|
|
497
|
+
es.addEventListener("error", (error) => { if (!closed)
|
|
498
|
+
opts.onError?.(error); });
|
|
377
499
|
}
|
|
378
|
-
return { 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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
407
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
headers,
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
-
|
|
628
|
+
payload = JSON.parse(data);
|
|
444
629
|
}
|
|
445
630
|
catch {
|
|
446
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
483
|
-
|
|
696
|
+
break;
|
|
697
|
+
reportError(error);
|
|
484
698
|
}
|
|
485
699
|
finally {
|
|
486
|
-
reader
|
|
487
|
-
|
|
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 = () =>
|
|
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
|
-
|
|
544
|
-
|
|
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.
|
|
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 (
|
|
563
|
-
|
|
564
|
-
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)
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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) {
|