@astralform/js 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -120,6 +120,19 @@ interface AstralformBaseConfig {
120
120
  baseURL?: string;
121
121
  /** Supply a custom fetch (SSR, testing, custom interceptors). */
122
122
  fetch?: typeof globalThis.fetch;
123
+ /**
124
+ * Abort a REST request — connect, headers, AND body read — after this many
125
+ * milliseconds. Defaults to 30_000. Does not apply to `uploadFile` (large
126
+ * files on slow uplinks) or to SSE streaming, which is long-lived by design
127
+ * and carries its own `AbortSignal`.
128
+ *
129
+ * Note that unlike axios and friends, `0` does NOT mean "no timeout": any
130
+ * non-positive or non-finite value falls back to the default. REST calls
131
+ * cannot opt out of the deadline — an unbounded REST request is the bug
132
+ * this exists to prevent, and it fails silently (a stalled body strands the
133
+ * caller forever with nothing to react to).
134
+ */
135
+ timeoutMs?: number;
123
136
  }
124
137
  interface AstralformApiKeyConfig extends AstralformBaseConfig {
125
138
  /** Agent API key (`sk_live_...` or `sk_test_...`). */
@@ -773,6 +786,7 @@ interface ConversationAsset {
773
786
  declare class AstralformClient {
774
787
  private readonly baseURL;
775
788
  private readonly fetchFn;
789
+ private readonly timeoutMs;
776
790
  /**
777
791
  * Auth state is mutable so callers can rotate access tokens or switch
778
792
  * agent context without re-instantiating the client. API-key mode is
@@ -819,7 +833,23 @@ declare class AstralformClient {
819
833
  */
820
834
  private get authHeaders();
821
835
  private get headers();
836
+ /**
837
+ * Run one REST exchange under a single deadline covering connect, headers,
838
+ * AND the body read. The body read is the part that matters: `json()` used
839
+ * to sit outside every guard, so a response whose headers arrived but whose
840
+ * body stalled hung forever — silently stranding callers that await it
841
+ * (a stalled `getMessages` used to leave `StreamManager.restore()` parked
842
+ * before it ever fetched the events it renders from).
843
+ *
844
+ * The controller is created per request and is deliberately NOT the
845
+ * session's — that one means "the user cancelled this turn" and is null
846
+ * outside a live turn. Aborting frees the socket; the race guarantees a
847
+ * rejection even when an injected `fetch` ignores the signal.
848
+ */
849
+ private withDeadline;
822
850
  private request;
851
+ /** Fetch + status handling. Always called inside `withDeadline`. */
852
+ private send;
823
853
  get<T>(path: string): Promise<T>;
824
854
  post<T>(path: string, body: unknown): Promise<T>;
825
855
  private del;
package/dist/index.d.ts CHANGED
@@ -120,6 +120,19 @@ interface AstralformBaseConfig {
120
120
  baseURL?: string;
121
121
  /** Supply a custom fetch (SSR, testing, custom interceptors). */
122
122
  fetch?: typeof globalThis.fetch;
123
+ /**
124
+ * Abort a REST request — connect, headers, AND body read — after this many
125
+ * milliseconds. Defaults to 30_000. Does not apply to `uploadFile` (large
126
+ * files on slow uplinks) or to SSE streaming, which is long-lived by design
127
+ * and carries its own `AbortSignal`.
128
+ *
129
+ * Note that unlike axios and friends, `0` does NOT mean "no timeout": any
130
+ * non-positive or non-finite value falls back to the default. REST calls
131
+ * cannot opt out of the deadline — an unbounded REST request is the bug
132
+ * this exists to prevent, and it fails silently (a stalled body strands the
133
+ * caller forever with nothing to react to).
134
+ */
135
+ timeoutMs?: number;
123
136
  }
124
137
  interface AstralformApiKeyConfig extends AstralformBaseConfig {
125
138
  /** Agent API key (`sk_live_...` or `sk_test_...`). */
@@ -773,6 +786,7 @@ interface ConversationAsset {
773
786
  declare class AstralformClient {
774
787
  private readonly baseURL;
775
788
  private readonly fetchFn;
789
+ private readonly timeoutMs;
776
790
  /**
777
791
  * Auth state is mutable so callers can rotate access tokens or switch
778
792
  * agent context without re-instantiating the client. API-key mode is
@@ -819,7 +833,23 @@ declare class AstralformClient {
819
833
  */
820
834
  private get authHeaders();
821
835
  private get headers();
836
+ /**
837
+ * Run one REST exchange under a single deadline covering connect, headers,
838
+ * AND the body read. The body read is the part that matters: `json()` used
839
+ * to sit outside every guard, so a response whose headers arrived but whose
840
+ * body stalled hung forever — silently stranding callers that await it
841
+ * (a stalled `getMessages` used to leave `StreamManager.restore()` parked
842
+ * before it ever fetched the events it renders from).
843
+ *
844
+ * The controller is created per request and is deliberately NOT the
845
+ * session's — that one means "the user cancelled this turn" and is null
846
+ * outside a live turn. Aborting frees the socket; the race guarantees a
847
+ * rejection even when an injected `fetch` ignores the signal.
848
+ */
849
+ private withDeadline;
822
850
  private request;
851
+ /** Fetch + status handling. Always called inside `withDeadline`. */
852
+ private send;
823
853
  get<T>(path: string): Promise<T>;
824
854
  post<T>(path: string, body: unknown): Promise<T>;
825
855
  private del;
package/dist/index.js CHANGED
@@ -252,6 +252,7 @@ async function* streamJobSSE(options) {
252
252
 
253
253
  // src/client.ts
254
254
  var DEFAULT_BASE_URL = "https://api.astralform.ai";
255
+ var DEFAULT_TIMEOUT_MS = 3e4;
255
256
  function validateBaseURL(url) {
256
257
  const cleaned = url.replace(/\/+$/, "");
257
258
  try {
@@ -302,6 +303,7 @@ var AstralformClient = class {
302
303
  }
303
304
  this.baseURL = validateBaseURL(config.baseURL ?? DEFAULT_BASE_URL);
304
305
  this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
306
+ this.timeoutMs = typeof config.timeoutMs === "number" && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS;
305
307
  }
306
308
  /**
307
309
  * Replace the current OIDC access token without reconstructing the client.
@@ -392,11 +394,48 @@ var AstralformClient = class {
392
394
  "Content-Type": "application/json"
393
395
  };
394
396
  }
397
+ /**
398
+ * Run one REST exchange under a single deadline covering connect, headers,
399
+ * AND the body read. The body read is the part that matters: `json()` used
400
+ * to sit outside every guard, so a response whose headers arrived but whose
401
+ * body stalled hung forever — silently stranding callers that await it
402
+ * (a stalled `getMessages` used to leave `StreamManager.restore()` parked
403
+ * before it ever fetched the events it renders from).
404
+ *
405
+ * The controller is created per request and is deliberately NOT the
406
+ * session's — that one means "the user cancelled this turn" and is null
407
+ * outside a live turn. Aborting frees the socket; the race guarantees a
408
+ * rejection even when an injected `fetch` ignores the signal.
409
+ */
410
+ async withDeadline(run) {
411
+ const controller = new AbortController();
412
+ const timedOut = () => new ConnectionError(`Request timed out after ${this.timeoutMs}ms`);
413
+ let timer;
414
+ const deadline = new Promise((_resolve, reject) => {
415
+ timer = setTimeout(() => {
416
+ controller.abort();
417
+ reject(timedOut());
418
+ }, this.timeoutMs);
419
+ });
420
+ try {
421
+ return await Promise.race([run(controller.signal), deadline]);
422
+ } catch (err) {
423
+ if (controller.signal.aborted) throw timedOut();
424
+ throw err;
425
+ } finally {
426
+ clearTimeout(timer);
427
+ }
428
+ }
395
429
  async request(method, path, body) {
430
+ return this.withDeadline((signal) => this.send(method, path, body, signal));
431
+ }
432
+ /** Fetch + status handling. Always called inside `withDeadline`. */
433
+ async send(method, path, body, signal) {
396
434
  const response = await this.fetchFn(`${this.baseURL}${path}`, {
397
435
  method,
398
436
  headers: this.headers,
399
- body: body !== void 0 ? JSON.stringify(body) : void 0
437
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
438
+ signal
400
439
  }).catch((err) => {
401
440
  throw new ConnectionError(
402
441
  err instanceof Error ? err.message : "Failed to connect"
@@ -405,13 +444,21 @@ var AstralformClient = class {
405
444
  await this.handleError(response);
406
445
  return response;
407
446
  }
447
+ // DO NOT refactor these back into `request()` + `.json()`. Parsing the body
448
+ // INSIDE the raced callback is the entire fix: `json()` outside the deadline
449
+ // is the original bug (headers arrive, body stalls, caller hangs forever).
450
+ // `request()` survives for `del()`, which never reads the body.
408
451
  async get(path) {
409
- const response = await this.request("GET", path);
410
- return response.json();
452
+ return this.withDeadline(async (signal) => {
453
+ const response = await this.send("GET", path, void 0, signal);
454
+ return await response.json();
455
+ });
411
456
  }
412
457
  async post(path, body) {
413
- const response = await this.request("POST", path, body);
414
- return response.json();
458
+ return this.withDeadline(async (signal) => {
459
+ const response = await this.send("POST", path, body, signal);
460
+ return await response.json();
461
+ });
415
462
  }
416
463
  async del(path) {
417
464
  await this.request("DELETE", path);