@astralform/js 4.1.0 → 4.3.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/README.md +6 -0
- package/dist/index.cjs +156 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +110 -2
- package/dist/index.d.ts +110 -2
- package/dist/index.js +155 -7
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -61,9 +61,15 @@ const session = new ChatSession({
|
|
|
61
61
|
userId: "user-123", // Required — identifies the end user
|
|
62
62
|
baseURL: "http://localhost:8000", // Optional — defaults to https://api.astralform.ai
|
|
63
63
|
fetch: customFetch, // Optional — custom fetch implementation
|
|
64
|
+
timeoutMs: 30_000, // Optional — REST request deadline, defaults to 30s
|
|
64
65
|
});
|
|
65
66
|
```
|
|
66
67
|
|
|
68
|
+
`timeoutMs` bounds each REST request end to end — connect, headers, and the
|
|
69
|
+
body read — rejecting with `ConnectionError` when it expires. File uploads and
|
|
70
|
+
the SSE stream are exempt: both are long-running by design, and the stream
|
|
71
|
+
carries its own `AbortSignal`.
|
|
72
|
+
|
|
67
73
|
## Events
|
|
68
74
|
|
|
69
75
|
Subscribe to events with `.on()`, which returns an unsubscribe function. The SDK forwards a typed `ChatEvent` for every wire event — consumers build their own block / message state from the stream.
|
package/dist/index.cjs
CHANGED
|
@@ -23,6 +23,7 @@ __export(index_exports, {
|
|
|
23
23
|
AstralformClient: () => AstralformClient,
|
|
24
24
|
AstralformError: () => AstralformError,
|
|
25
25
|
AuthenticationError: () => AuthenticationError,
|
|
26
|
+
CONVERSATION_PAGE_SIZE: () => CONVERSATION_PAGE_SIZE,
|
|
26
27
|
ChatEventType: () => ChatEventType,
|
|
27
28
|
ChatSession: () => ChatSession,
|
|
28
29
|
ConnectionError: () => ConnectionError,
|
|
@@ -298,6 +299,7 @@ async function* streamJobSSE(options) {
|
|
|
298
299
|
|
|
299
300
|
// src/client.ts
|
|
300
301
|
var DEFAULT_BASE_URL = "https://api.astralform.ai";
|
|
302
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
301
303
|
function validateBaseURL(url) {
|
|
302
304
|
const cleaned = url.replace(/\/+$/, "");
|
|
303
305
|
try {
|
|
@@ -348,10 +350,11 @@ var AstralformClient = class {
|
|
|
348
350
|
}
|
|
349
351
|
this.baseURL = validateBaseURL(config.baseURL ?? DEFAULT_BASE_URL);
|
|
350
352
|
this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
353
|
+
this.timeoutMs = typeof config.timeoutMs === "number" && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
351
354
|
}
|
|
352
355
|
/**
|
|
353
356
|
* Replace the current OIDC access token without reconstructing the client.
|
|
354
|
-
* Use after refreshing via the host's token manager
|
|
357
|
+
* Use after refreshing via the host app's own token manager.
|
|
355
358
|
* Throws if the client was created in API-key mode.
|
|
356
359
|
*/
|
|
357
360
|
updateAccessToken(accessToken) {
|
|
@@ -438,11 +441,48 @@ var AstralformClient = class {
|
|
|
438
441
|
"Content-Type": "application/json"
|
|
439
442
|
};
|
|
440
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* Run one REST exchange under a single deadline covering connect, headers,
|
|
446
|
+
* AND the body read. The body read is the part that matters: `json()` used
|
|
447
|
+
* to sit outside every guard, so a response whose headers arrived but whose
|
|
448
|
+
* body stalled hung forever — silently stranding callers that await it
|
|
449
|
+
* (a stalled `getMessages` used to leave `StreamManager.restore()` parked
|
|
450
|
+
* before it ever fetched the events it renders from).
|
|
451
|
+
*
|
|
452
|
+
* The controller is created per request and is deliberately NOT the
|
|
453
|
+
* session's — that one means "the user cancelled this turn" and is null
|
|
454
|
+
* outside a live turn. Aborting frees the socket; the race guarantees a
|
|
455
|
+
* rejection even when an injected `fetch` ignores the signal.
|
|
456
|
+
*/
|
|
457
|
+
async withDeadline(run) {
|
|
458
|
+
const controller = new AbortController();
|
|
459
|
+
const timedOut = () => new ConnectionError(`Request timed out after ${this.timeoutMs}ms`);
|
|
460
|
+
let timer;
|
|
461
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
462
|
+
timer = setTimeout(() => {
|
|
463
|
+
controller.abort();
|
|
464
|
+
reject(timedOut());
|
|
465
|
+
}, this.timeoutMs);
|
|
466
|
+
});
|
|
467
|
+
try {
|
|
468
|
+
return await Promise.race([run(controller.signal), deadline]);
|
|
469
|
+
} catch (err) {
|
|
470
|
+
if (controller.signal.aborted) throw timedOut();
|
|
471
|
+
throw err;
|
|
472
|
+
} finally {
|
|
473
|
+
clearTimeout(timer);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
441
476
|
async request(method, path, body) {
|
|
477
|
+
return this.withDeadline((signal) => this.send(method, path, body, signal));
|
|
478
|
+
}
|
|
479
|
+
/** Fetch + status handling. Always called inside `withDeadline`. */
|
|
480
|
+
async send(method, path, body, signal) {
|
|
442
481
|
const response = await this.fetchFn(`${this.baseURL}${path}`, {
|
|
443
482
|
method,
|
|
444
483
|
headers: this.headers,
|
|
445
|
-
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
484
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
485
|
+
signal
|
|
446
486
|
}).catch((err) => {
|
|
447
487
|
throw new ConnectionError(
|
|
448
488
|
err instanceof Error ? err.message : "Failed to connect"
|
|
@@ -451,13 +491,21 @@ var AstralformClient = class {
|
|
|
451
491
|
await this.handleError(response);
|
|
452
492
|
return response;
|
|
453
493
|
}
|
|
494
|
+
// DO NOT refactor these back into `request()` + `.json()`. Parsing the body
|
|
495
|
+
// INSIDE the raced callback is the entire fix: `json()` outside the deadline
|
|
496
|
+
// is the original bug (headers arrive, body stalls, caller hangs forever).
|
|
497
|
+
// `request()` survives for `del()`, which never reads the body.
|
|
454
498
|
async get(path) {
|
|
455
|
-
|
|
456
|
-
|
|
499
|
+
return this.withDeadline(async (signal) => {
|
|
500
|
+
const response = await this.send("GET", path, void 0, signal);
|
|
501
|
+
return await response.json();
|
|
502
|
+
});
|
|
457
503
|
}
|
|
458
504
|
async post(path, body) {
|
|
459
|
-
|
|
460
|
-
|
|
505
|
+
return this.withDeadline(async (signal) => {
|
|
506
|
+
const response = await this.send("POST", path, body, signal);
|
|
507
|
+
return await response.json();
|
|
508
|
+
});
|
|
461
509
|
}
|
|
462
510
|
async del(path) {
|
|
463
511
|
await this.request("DELETE", path);
|
|
@@ -1231,6 +1279,7 @@ function translateWireEvent(wire) {
|
|
|
1231
1279
|
// src/session.ts
|
|
1232
1280
|
var SSE_MAX_RECONNECTS = 6;
|
|
1233
1281
|
var TOOL_RESULT_MAX_RETRIES = 3;
|
|
1282
|
+
var CONVERSATION_PAGE_SIZE = 50;
|
|
1234
1283
|
function sseReconnectDelayMs(attempt) {
|
|
1235
1284
|
return Math.min(500 * 2 ** (attempt - 1), 5e3);
|
|
1236
1285
|
}
|
|
@@ -1254,6 +1303,17 @@ var ChatSession = class {
|
|
|
1254
1303
|
// State
|
|
1255
1304
|
this.conversationId = null;
|
|
1256
1305
|
this.conversations = [];
|
|
1306
|
+
/**
|
|
1307
|
+
* Whether another page of conversations may exist on the server.
|
|
1308
|
+
*
|
|
1309
|
+
* Inferred from the last page being full, since the list endpoint returns a
|
|
1310
|
+
* bare array with no total. A total that happens to be an exact multiple of
|
|
1311
|
+
* the page size therefore costs one extra empty request before this flips —
|
|
1312
|
+
* cheaper than adding a count query to every list call.
|
|
1313
|
+
*/
|
|
1314
|
+
this.hasMoreConversations = false;
|
|
1315
|
+
/** True while ``loadMoreConversations`` is in flight. */
|
|
1316
|
+
this.isLoadingConversations = false;
|
|
1257
1317
|
this.messages = [];
|
|
1258
1318
|
this.isStreaming = false;
|
|
1259
1319
|
this.agentStatus = null;
|
|
@@ -1261,6 +1321,32 @@ var ChatSession = class {
|
|
|
1261
1321
|
this.skills = [];
|
|
1262
1322
|
this.enabledClientTools = /* @__PURE__ */ new Set();
|
|
1263
1323
|
this.modelDisplayName = null;
|
|
1324
|
+
/**
|
|
1325
|
+
* Ids of conversations the SERVER has handed us, which is the paging offset.
|
|
1326
|
+
*
|
|
1327
|
+
* Deliberately not ``conversations.length``. That array also holds
|
|
1328
|
+
* conversations created locally and unshifted on top (``createNewConversation``,
|
|
1329
|
+
* and the auto-created conversation in ``consumeJobStream``), so using its
|
|
1330
|
+
* length as the offset would over-count and silently skip a row of real
|
|
1331
|
+
* history on the next page. Tracking ids rather than a counter also makes
|
|
1332
|
+
* deletion self-correcting: removing a server-sourced conversation shifts
|
|
1333
|
+
* every later page up by one, and dropping its id from this set is exactly
|
|
1334
|
+
* that shift — while deleting a purely local one correctly changes nothing.
|
|
1335
|
+
*/
|
|
1336
|
+
this.serverConversationIds = /* @__PURE__ */ new Set();
|
|
1337
|
+
/**
|
|
1338
|
+
* Bumped every time ``connect()`` re-seeds the conversation list.
|
|
1339
|
+
*
|
|
1340
|
+
* A ``loadMoreConversations`` request issued before a re-seed describes the
|
|
1341
|
+
* OLD paging state, so applying its response afterwards both appends the
|
|
1342
|
+
* wrong rows and corrupts the offset. Concretely: with 100 rows held, an
|
|
1343
|
+
* offset-100 response landing after a reconnect has reset to rows 0-49 would
|
|
1344
|
+
* append rows 100-149 — a 50-row hole — and leave the id set at 100, so every
|
|
1345
|
+
* later page re-requests offset 100 and never advances again. The generation
|
|
1346
|
+
* is captured before the await and rechecked after, so a superseded response
|
|
1347
|
+
* is discarded instead.
|
|
1348
|
+
*/
|
|
1349
|
+
this.conversationsGeneration = 0;
|
|
1264
1350
|
// Minimal in-session accumulation for the assistant message record.
|
|
1265
1351
|
// Only top-level ``text`` blocks contribute; subagent / tool output
|
|
1266
1352
|
// is tracked by the consumer's own block store.
|
|
@@ -1300,7 +1386,7 @@ var ChatSession = class {
|
|
|
1300
1386
|
async connect() {
|
|
1301
1387
|
const [status, conversations, agents, skills] = await Promise.allSettled([
|
|
1302
1388
|
this.client.getAgentStatus(),
|
|
1303
|
-
this.client.getConversations(),
|
|
1389
|
+
this.client.getConversations(CONVERSATION_PAGE_SIZE),
|
|
1304
1390
|
this.client.getAgents().catch(() => []),
|
|
1305
1391
|
this.client.getSkills().catch(() => [])
|
|
1306
1392
|
]);
|
|
@@ -1308,7 +1394,12 @@ var ChatSession = class {
|
|
|
1308
1394
|
this.agentStatus = status.value;
|
|
1309
1395
|
}
|
|
1310
1396
|
if (conversations.status === "fulfilled") {
|
|
1397
|
+
this.conversationsGeneration++;
|
|
1311
1398
|
this.conversations = conversations.value;
|
|
1399
|
+
this.serverConversationIds = new Set(
|
|
1400
|
+
conversations.value.map((c) => c.id)
|
|
1401
|
+
);
|
|
1402
|
+
this.hasMoreConversations = conversations.value.length === CONVERSATION_PAGE_SIZE;
|
|
1312
1403
|
}
|
|
1313
1404
|
if (agents.status === "fulfilled") {
|
|
1314
1405
|
this.agents = agents.value;
|
|
@@ -1767,12 +1858,69 @@ var ChatSession = class {
|
|
|
1767
1858
|
eventsResult.status === "fulfilled" ? eventsResult.value : []
|
|
1768
1859
|
);
|
|
1769
1860
|
}
|
|
1861
|
+
/**
|
|
1862
|
+
* Append the next page of conversation history to ``conversations``.
|
|
1863
|
+
*
|
|
1864
|
+
* The list is ordered ``updated_at DESC`` and paged by offset, so a
|
|
1865
|
+
* conversation bumped to the top mid-scroll can surface again in a later
|
|
1866
|
+
* page; ids already held are dropped rather than duplicated. Returns only
|
|
1867
|
+
* the conversations actually appended, which may be empty even on a full
|
|
1868
|
+
* page. Rejects on network failure with ``hasMoreConversations`` still true,
|
|
1869
|
+
* so the caller can retry.
|
|
1870
|
+
*
|
|
1871
|
+
* KNOWN LIMITATION — offset paging is only stable while the prefix already
|
|
1872
|
+
* consumed stays put. The offset tracking here corrects for perturbations
|
|
1873
|
+
* THIS session causes (local unshifts, ``deleteConversation``), but not for
|
|
1874
|
+
* ones it never sees:
|
|
1875
|
+
*
|
|
1876
|
+
* - a conversation this session hasn't loaded yet is bumped to the top (a
|
|
1877
|
+
* headless routine or another device posting to it), pushing the whole
|
|
1878
|
+
* list down — it lands inside the consumed prefix, which no later offset
|
|
1879
|
+
* revisits;
|
|
1880
|
+
* - a conversation is deleted from another tab/device, shrinking the list so
|
|
1881
|
+
* the next offset lands one row too far in.
|
|
1882
|
+
*
|
|
1883
|
+
* Each perturbation costs at most one conversation off the sidebar, and only
|
|
1884
|
+
* until the next ``connect()`` — that re-seeds page 1 and resets the paging
|
|
1885
|
+
* state, so a reload or reconnect always recovers it. Nothing is lost
|
|
1886
|
+
* server-side. Both cases are pinned by tests in
|
|
1887
|
+
* ``tests/conversation-paging.test.ts``.
|
|
1888
|
+
*
|
|
1889
|
+
* Closing the gap properly needs a stable server cursor (keyset paging on
|
|
1890
|
+
* ``(updated_at, id)``) rather than a raw offset, which is a backend change —
|
|
1891
|
+
* tracking ids client-side cannot discover a row that moved into a region
|
|
1892
|
+
* already scanned.
|
|
1893
|
+
*/
|
|
1894
|
+
async loadMoreConversations() {
|
|
1895
|
+
if (this.isLoadingConversations || !this.hasMoreConversations) return [];
|
|
1896
|
+
this.isLoadingConversations = true;
|
|
1897
|
+
const generation = this.conversationsGeneration;
|
|
1898
|
+
try {
|
|
1899
|
+
const page = await this.client.getConversations(
|
|
1900
|
+
CONVERSATION_PAGE_SIZE,
|
|
1901
|
+
this.serverConversationIds.size
|
|
1902
|
+
);
|
|
1903
|
+
if (generation !== this.conversationsGeneration) return [];
|
|
1904
|
+
this.hasMoreConversations = page.length === CONVERSATION_PAGE_SIZE;
|
|
1905
|
+
const fresh = page.filter((c) => !this.serverConversationIds.has(c.id));
|
|
1906
|
+
for (const c of page) this.serverConversationIds.add(c.id);
|
|
1907
|
+
const known = new Set(this.conversations.map((c) => c.id));
|
|
1908
|
+
const appended = fresh.filter((c) => !known.has(c.id));
|
|
1909
|
+
this.conversations.push(...appended);
|
|
1910
|
+
return appended;
|
|
1911
|
+
} finally {
|
|
1912
|
+
this.isLoadingConversations = false;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1770
1915
|
async deleteConversation(id) {
|
|
1771
1916
|
try {
|
|
1772
1917
|
await this.client.deleteConversation(id);
|
|
1773
1918
|
} catch {
|
|
1774
1919
|
}
|
|
1775
1920
|
await this.storage.deleteConversation(id);
|
|
1921
|
+
if (this.serverConversationIds.delete(id)) {
|
|
1922
|
+
this.conversationsGeneration++;
|
|
1923
|
+
}
|
|
1776
1924
|
this.conversations = this.conversations.filter((c) => c.id !== id);
|
|
1777
1925
|
if (this.conversationId === id) {
|
|
1778
1926
|
this.conversationId = null;
|
|
@@ -2158,6 +2306,7 @@ function parseEmbeddedResource(value) {
|
|
|
2158
2306
|
AstralformClient,
|
|
2159
2307
|
AstralformError,
|
|
2160
2308
|
AuthenticationError,
|
|
2309
|
+
CONVERSATION_PAGE_SIZE,
|
|
2161
2310
|
ChatEventType,
|
|
2162
2311
|
ChatSession,
|
|
2163
2312
|
ConnectionError,
|