@pouchy_ai/companion-sdk 0.33.0 → 0.35.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/client.js CHANGED
@@ -42,6 +42,35 @@ export function pouchyBrandIconUrl(baseUrl, size = 512) {
42
42
  * (so call.ts shares it cycle-free); re-exported here so the public import
43
43
  * path is unchanged. */
44
44
  export { CompanionError } from './errors.js';
45
+ // Default outbox persistence: localStorage when the environment has one (and
46
+ // allows it — privacy mode / quota errors fall through), else a module-level
47
+ // Map so the queue at least survives for the page's lifetime.
48
+ const memoryOutbox = new Map();
49
+ const defaultOutboxStore = {
50
+ load(key) {
51
+ try {
52
+ if (typeof window !== 'undefined' && window.localStorage) {
53
+ return window.localStorage.getItem(key);
54
+ }
55
+ }
56
+ catch {
57
+ /* storage denied — fall through to the in-memory copy */
58
+ }
59
+ return memoryOutbox.get(key) ?? null;
60
+ },
61
+ save(key, value) {
62
+ try {
63
+ if (typeof window !== 'undefined' && window.localStorage) {
64
+ window.localStorage.setItem(key, value);
65
+ return;
66
+ }
67
+ }
68
+ catch {
69
+ /* quota / privacy mode — keep the in-memory copy below */
70
+ }
71
+ memoryOutbox.set(key, value);
72
+ }
73
+ };
45
74
  const SEEN_CAP = 512;
46
75
  export class CompanionClient {
47
76
  opts;
@@ -68,6 +97,17 @@ export class CompanionClient {
68
97
  // Instrumentation sink (0.32.0) — null when `debug` is unset, so the hot
69
98
  // paths pay one falsy check.
70
99
  debugSink;
100
+ // Offline send queue (0.34.0) — null until first use, then loaded lazily
101
+ // from the store so a reloaded tab resumes the queue its predecessor
102
+ // persisted.
103
+ outbox = null;
104
+ outboxHandlers = new Set();
105
+ // In-flight flush, shared so concurrent triggers run ONE flush at a time.
106
+ outboxFlush = null;
107
+ /** Epoch-ms before which flush attempts are skipped — set from a 429's
108
+ * Retry-After during replay so the auto-flush on every reconnect doesn't
109
+ * re-hammer a server that just told us to back off (0.35.0). */
110
+ outboxBlockedUntil = 0;
71
111
  constructor(opts) {
72
112
  if (!opts.baseUrl)
73
113
  throw new CompanionError('CompanionClient: baseUrl is required', 0, 'missing_option');
@@ -129,6 +169,10 @@ export class CompanionClient {
129
169
  console.error('[companion-sdk] stream-state handler threw', e);
130
170
  }
131
171
  }
172
+ // The stream coming back up is the strongest "server reachable again"
173
+ // signal there is — drain any sends queued while offline (0.34.0).
174
+ if (next === 'connected' && this.opts.queueOffline)
175
+ void this.flushOutbox().catch(() => { });
132
176
  }
133
177
  /** Swap the bearer token used by every subsequent request and stream
134
178
  * reconnect. Session tokens expire (default 1h) — call this when your
@@ -175,6 +219,38 @@ export class CompanionClient {
175
219
  const minor = Number(m[2]);
176
220
  return major > 1 || (major === 1 && minor >= 1);
177
221
  }
222
+ /** doFetch raced against the `requestTimeoutMs` HEADERS deadline (0.35.0).
223
+ * The deadline only bounds time-to-response-headers: it does NOT swap the
224
+ * request's AbortSignal, so a streaming response body stays tied to the
225
+ * caller's own signal, and the timer is cleared the moment the race
226
+ * settles. On timeout the helper rejects `request_timeout` (status 0) —
227
+ * code-bearing, so the offline queue's network-failure predicate correctly
228
+ * ignores it (the server may have received the request). The abandoned
229
+ * fetch keeps its rejection silenced so a late network error can't surface
230
+ * as an unhandled rejection. */
231
+ async doFetchBounded(url, init) {
232
+ const budget = this.opts.requestTimeoutMs ?? 30_000;
233
+ if (!budget)
234
+ return this.doFetch(url, init);
235
+ const req = this.doFetch(url, init);
236
+ let timer;
237
+ try {
238
+ return await Promise.race([
239
+ req,
240
+ new Promise((_, reject) => {
241
+ timer = setTimeout(() => reject(new CompanionError(`request timed out after ${budget}ms waiting for response headers`, 0, 'request_timeout')), budget);
242
+ })
243
+ ]);
244
+ }
245
+ catch (e) {
246
+ req.catch(() => { });
247
+ throw e;
248
+ }
249
+ finally {
250
+ if (timer !== undefined)
251
+ clearTimeout(timer);
252
+ }
253
+ }
178
254
  async fetchAuthed(url, init) {
179
255
  const method = init.method ?? 'GET';
180
256
  const path = url.startsWith(this.opts.baseUrl) ? url.slice(this.opts.baseUrl.length) : url;
@@ -182,7 +258,7 @@ export class CompanionClient {
182
258
  this.dbg({ kind: 'request', at: startedAt, method, path });
183
259
  let res;
184
260
  try {
185
- res = await this.doFetch(url, init);
261
+ res = await this.doFetchBounded(url, init);
186
262
  }
187
263
  catch (e) {
188
264
  // A caller-supplied AbortSignal makes fetch throw a native AbortError —
@@ -217,7 +293,7 @@ export class CompanionClient {
217
293
  const retryAt = Date.now();
218
294
  this.dbg({ kind: 'request', at: retryAt, method, path });
219
295
  try {
220
- const retryRes = await this.doFetch(url, init);
296
+ const retryRes = await this.doFetchBounded(url, init);
221
297
  this.dbg({
222
298
  kind: 'response',
223
299
  at: Date.now(),
@@ -322,6 +398,10 @@ export class CompanionClient {
322
398
  }, opts?.signal);
323
399
  this._session = json.session;
324
400
  this.cursor = json.resumeCursor ?? 0;
401
+ // A successful handshake proves reachability — replay any sends queued
402
+ // while offline (0.34.0; no-op when the queue is empty).
403
+ if (this.opts.queueOffline)
404
+ void this.flushOutbox().catch(() => { });
325
405
  return {
326
406
  ...json,
327
407
  representative: json.representative ?? false,
@@ -356,6 +436,26 @@ export class CompanionClient {
356
436
  // message that happens by (world-state reactions, confirm outcomes).
357
437
  const turnId = `turn_${this.randomId()}`;
358
438
  const body = { text, turnId, ...(opts?.images?.length ? { images: opts.images } : {}) };
439
+ if (!this.opts.queueOffline)
440
+ return this.dispatchText(id, body, opts, turnId);
441
+ // Offline queue: two triggers only — the browser already knows it's
442
+ // offline, or fetch itself rejected without an HTTP response. An HTTP
443
+ // error (4xx/5xx incl. 429) means the server SAW the turn: those keep
444
+ // the normal throw semantics and are never queued.
445
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) {
446
+ return this.queueSend(turnId, text, opts);
447
+ }
448
+ try {
449
+ return await this.dispatchText(id, body, opts, turnId);
450
+ }
451
+ catch (e) {
452
+ if (this.isNetworkSendFailure(e))
453
+ return this.queueSend(turnId, text, opts);
454
+ throw e;
455
+ }
456
+ }
457
+ /** The pre-queue dispatch of sendText: awaitReply / streaming / buffered. */
458
+ async dispatchText(id, body, opts, turnId) {
359
459
  if (opts?.awaitReply)
360
460
  return this.sendTextAwaitingReply(id, body, opts, turnId);
361
461
  const wantStream = opts?.stream ?? this.deltaHandlers.size > 0;
@@ -365,6 +465,32 @@ export class CompanionClient {
365
465
  }
366
466
  return this.sendTextStreaming(id, body, opts?.signal);
367
467
  }
468
+ /** True only for a send that never reached the server: fetch rejected at
469
+ * the network level, which asCompanionError wraps as status 0 with NO
470
+ * code. Every code-carrying status-0 error is deliberate — `aborted` is
471
+ * the caller cancelling, `reply_timeout`/`needs_event_stream` mean the
472
+ * POST was accepted — and any nonzero status is a response the server
473
+ * produced. None of those may queue. */
474
+ isNetworkSendFailure(e) {
475
+ return e instanceof CompanionError && e.status === 0 && e.code === undefined;
476
+ }
477
+ /** Enqueue an offline send. Plain sends resolve `{ seq: null, queued: true,
478
+ * turnId }`; awaitReply can't await a reply that won't arrive this
479
+ * session, so it rejects with `queued_offline` — the item is queued
480
+ * either way. */
481
+ queueSend(turnId, text, opts) {
482
+ this.loadOutbox().push({
483
+ turnId,
484
+ text,
485
+ ...(opts?.images?.length ? { images: opts.images } : {}),
486
+ at: Date.now()
487
+ });
488
+ this.commitOutbox();
489
+ if (opts?.awaitReply) {
490
+ throw new CompanionError('offline — the text was queued and will send when the connection returns; no reply to await', 0, 'queued_offline');
491
+ }
492
+ return { seq: null, queued: true, turnId };
493
+ }
368
494
  /** The awaitReply leg of sendText. Prefers the in-request answer (the
369
495
  * streaming `done` frame's envelope); falls back to the next
370
496
  * `companion.message` off the event stream, bounded by a timeout. The
@@ -372,6 +498,13 @@ export class CompanionClient {
372
498
  * can't slip through the gap. */
373
499
  async sendTextAwaitingReply(sessionId, body, opts, turnId) {
374
500
  const timeoutMs = opts?.replyTimeoutMs ?? 60_000;
501
+ // Already-aborted fast-fail comes FIRST (0.35.0): it used to sit after the
502
+ // companion.message subscription below, whose cleanup lives in the finally
503
+ // of the try we never entered — every call with a spent signal leaked one
504
+ // permanent handler (a host reusing one AbortController across turns
505
+ // accumulated them without bound).
506
+ if (opts?.signal?.aborted)
507
+ throw new CompanionError('request aborted', 0, 'aborted');
375
508
  let resolveFallback;
376
509
  const fallback = new Promise((res) => (resolveFallback = res));
377
510
  // Correlate: resolve only on THIS turn's reply (`replyTo === turnId`).
@@ -392,11 +525,9 @@ export class CompanionClient {
392
525
  const ac = new AbortController();
393
526
  // Link the caller's signal: aborting it tears down the in-flight stream
394
527
  // (via ac) AND rejects the whole operation with `aborted`, exactly like
395
- // the deadline does. If it is already aborted, fail fast.
528
+ // the deadline does.
396
529
  let onCallerAbort;
397
530
  if (opts?.signal) {
398
- if (opts.signal.aborted)
399
- throw new CompanionError('request aborted', 0, 'aborted');
400
531
  onCallerAbort = () => ac.abort();
401
532
  opts.signal.addEventListener('abort', onCallerAbort, { once: true });
402
533
  }
@@ -567,9 +698,131 @@ export class CompanionClient {
567
698
  catch {
568
699
  /* stream already finished */
569
700
  }
570
- throw e;
701
+ // Normalize (0.35.0): a mid-stream network drop rejects reader.read()
702
+ // with a NATIVE TypeError. Rethrowing it raw broke the "every helper
703
+ // rejects with CompanionError" contract AND slipped past the offline
704
+ // queue's isNetworkSendFailure (which requires a code-less status-0
705
+ // CompanionError) — the exact loss queueOffline exists to prevent.
706
+ // Queueing the replay is safe: it reuses the ORIGINAL turnId and the
707
+ // server dedupes completed turns, so a turn that did run isn't re-run.
708
+ throw this.asCompanionError(e);
571
709
  }
572
710
  }
711
+ /** The live offline queue, loaded from the store on first use so a reloaded
712
+ * tab resumes what its predecessor persisted. Unreadable or foreign store
713
+ * content is discarded — a corrupt store must never break sending. */
714
+ loadOutbox() {
715
+ if (this.outbox)
716
+ return this.outbox;
717
+ let items = [];
718
+ try {
719
+ const raw = (this.opts.outboxStore ?? defaultOutboxStore).load(this.outboxKey());
720
+ const parsed = raw ? JSON.parse(raw) : null;
721
+ if (Array.isArray(parsed)) {
722
+ items = parsed.filter((q) => !!q &&
723
+ typeof q.turnId === 'string' &&
724
+ typeof q.text === 'string');
725
+ }
726
+ }
727
+ catch {
728
+ /* unreadable store — start empty */
729
+ }
730
+ this.outbox = items;
731
+ return items;
732
+ }
733
+ outboxKey() {
734
+ return `pouchy-outbox:${this.opts.surface ?? 'default'}`;
735
+ }
736
+ /** Persist + notify after EVERY queue mutation (enqueue / flushed / drop),
737
+ * so the stored copy never lags what a reload would need. Handler
738
+ * isolation matches emit(): a throwing consumer must not skip the rest. */
739
+ commitOutbox() {
740
+ const items = this.loadOutbox();
741
+ try {
742
+ (this.opts.outboxStore ?? defaultOutboxStore).save(this.outboxKey(), JSON.stringify(items));
743
+ }
744
+ catch {
745
+ /* store save failed — the in-memory queue still flushes this page */
746
+ }
747
+ const copy = Object.freeze([...items]);
748
+ for (const h of this.outboxHandlers) {
749
+ try {
750
+ h(copy);
751
+ }
752
+ catch (e) {
753
+ console.error('[companion-sdk] outbox handler threw', e);
754
+ }
755
+ }
756
+ }
757
+ /** The sends queued while offline (oldest first), as a defensive copy —
758
+ * see the `queueOffline` option. */
759
+ pendingOutbox() {
760
+ return [...this.loadOutbox()];
761
+ }
762
+ /** Subscribe to offline-queue changes (enqueue / flushed / dropped) — drive
763
+ * a "N pending" badge from `items.length`. Returns an unsubscribe fn. */
764
+ onOutboxChange(handler) {
765
+ this.outboxHandlers.add(handler);
766
+ return () => this.outboxHandlers.delete(handler);
767
+ }
768
+ /** Replay the offline queue: FIFO, sequential, each item a normal buffered
769
+ * input POST reusing its ORIGINAL `turnId` (the server dedupes completed
770
+ * turnIds — see `queueOffline` — so a retry can never double-run a turn).
771
+ * Runs automatically after connect() and on every stream reconnect; call
772
+ * it yourself for a manual retry affordance. One flush at a time —
773
+ * concurrent calls share the in-flight run. Per item: success (or a
774
+ * server-side duplicate) → removed; network failure / 5xx / 429 → stop,
775
+ * keep this item and the rest for the next flush; any other 4xx → the
776
+ * item itself is invalid (the server deliberately rejected it) — drop it,
777
+ * console.warn, and keep flushing. */
778
+ async flushOutbox() {
779
+ this.requireSession();
780
+ this.outboxFlush ??= this.runOutboxFlush().finally(() => {
781
+ this.outboxFlush = null;
782
+ });
783
+ return this.outboxFlush;
784
+ }
785
+ async runOutboxFlush() {
786
+ const id = this.requireSession();
787
+ const items = this.loadOutbox();
788
+ // Respect the last replay 429's Retry-After (0.35.0): the flush re-arms
789
+ // on EVERY stream reconnect, so without this gate a rate-limited server
790
+ // got re-hammered on each reconnect despite naming its own window.
791
+ if (Date.now() < this.outboxBlockedUntil)
792
+ return { sent: 0, remaining: items.length };
793
+ let sent = 0;
794
+ while (items.length) {
795
+ const item = items[0];
796
+ try {
797
+ await this.postJson(`/api/companion/session/${id}/input`, {
798
+ text: item.text,
799
+ turnId: item.turnId,
800
+ ...(item.images?.length ? { images: item.images } : {})
801
+ });
802
+ }
803
+ catch (e) {
804
+ const status = e instanceof CompanionError ? e.status : 0;
805
+ if (status >= 400 && status < 500 && status !== 429) {
806
+ // The server saw it and said no — retrying can't fix the item.
807
+ console.warn('[companion-sdk] dropping queued send the server rejected', status, e);
808
+ items.shift();
809
+ this.commitOutbox();
810
+ continue;
811
+ }
812
+ // A throttle names its own retry window — honor it across flushes.
813
+ if (status === 429 && e instanceof CompanionError && typeof e.retryAfter === 'number') {
814
+ this.outboxBlockedUntil = Date.now() + e.retryAfter * 1000;
815
+ }
816
+ // Unreachable again (or throttled / a server-side failure): keep
817
+ // this item and everything behind it for the next flush.
818
+ break;
819
+ }
820
+ items.shift();
821
+ sent += 1;
822
+ this.commitOutbox();
823
+ }
824
+ return { sent, remaining: items.length };
825
+ }
573
826
  /** Subscribe to token-streaming deltas of the reply (P1-2). Registering a
574
827
  * subscriber makes sendText stream automatically. Returns an unsubscribe
575
828
  * function. Render chunks as they arrive; on `meta.reset` clear the partial;
@@ -867,7 +1120,10 @@ export class CompanionClient {
867
1120
  * arrive as `control.error`. Since 0.30.0 `code` is typed as
868
1121
  * `ControlErrorCodeValue` (the CONTROL_ERROR_CODES vocabulary + the
869
1122
  * SDK-synthesized stream codes, open for newer servers) so a `switch` on it
870
- * autocompletes. Returns an unsubscribe fn. */
1123
+ * autocompletes. A malformed frame with no string `code` is delivered with
1124
+ * the literal fallback `'error'` (not part of the exported vocabulary) —
1125
+ * give your `switch` a `default:` arm rather than enumerating every code.
1126
+ * Returns an unsubscribe fn. */
871
1127
  onError(handler) {
872
1128
  return this.on('control.error', (env) => {
873
1129
  const p = env.payload;
@@ -1216,13 +1472,19 @@ export class CompanionClient {
1216
1472
  let backoff = 500;
1217
1473
  while (this.streaming && gen === this.streamGen) {
1218
1474
  let errored = false;
1219
- const connectedAt = Date.now();
1475
+ // Stamped in onopen (0.35.0) — measuring from before the constructor
1476
+ // counted DNS/TCP/handshake time as "connection lifetime", so a gateway
1477
+ // that took ~2s to accept and then instantly closed read as long-lived
1478
+ // and RESET the backoff (a near-hot reconnect loop). 0 = never opened,
1479
+ // which always backs off.
1480
+ let openedAt = 0;
1220
1481
  try {
1221
1482
  await new Promise((resolve, reject) => {
1222
1483
  const ws = new WS(deriveWsStreamUrl(this.opts.baseUrl, id, this.opts.token, this.cursor));
1223
1484
  let poll;
1224
1485
  let settled = false;
1225
1486
  ws.onopen = () => {
1487
+ openedAt = Date.now();
1226
1488
  if (this.streaming && gen === this.streamGen)
1227
1489
  this.setStreamState('connected');
1228
1490
  };
@@ -1284,7 +1546,7 @@ export class CompanionClient {
1284
1546
  // loop. A connection that lived a while resets the backoff.
1285
1547
  if (this.streaming && gen === this.streamGen)
1286
1548
  this.setStreamState('reconnecting');
1287
- if (Date.now() - connectedAt < 2000) {
1549
+ if (openedAt === 0 || Date.now() - openedAt < 2000) {
1288
1550
  await this.backoffSleep(backoff);
1289
1551
  backoff = Math.min(backoff * 2, 8000);
1290
1552
  }
@@ -1453,15 +1715,37 @@ export class CompanionClient {
1453
1715
  const reader = body.getReader();
1454
1716
  const decoder = new TextDecoder();
1455
1717
  let buf = '';
1456
- while (this.streaming && gen === this.streamGen) {
1457
- const { done, value } = await reader.read();
1458
- if (done)
1459
- break;
1460
- buf += decoder.decode(value, { stream: true });
1461
- const { events, rest } = parseSse(buf);
1462
- buf = rest;
1463
- for (const ev of events)
1464
- this.handleFrame(ev.event, ev.data);
1718
+ // Liveness watchdog (0.35.0). The server writes a `: ping` comment every
1719
+ // ~1.5s and closes each stream window at 45s — a healthy connection is
1720
+ // NEVER byte-silent for long. On a silent half-open TCP drop (NAT/LB
1721
+ // idle-kill, laptop sleep) no FIN ever arrives: reader.read() blocks for
1722
+ // minutes while streamState still reads 'connected' and every reply is
1723
+ // silently lost. 10× the ping interval without a single byte can only be
1724
+ // a dead connection — cancel the reader so read() returns and the loop
1725
+ // reconnects through its normal backoff + cursor resume (replays dedupe
1726
+ // by envelope id). Only the SSE plane gets this: the WS plane has no
1727
+ // heartbeat contract, so an idle-but-healthy socket can't be told apart.
1728
+ const SSE_STALL_MS = 15_000;
1729
+ let lastByteAt = Date.now();
1730
+ const watchdog = setInterval(() => {
1731
+ if (Date.now() - lastByteAt > SSE_STALL_MS)
1732
+ void reader.cancel().catch(() => { });
1733
+ }, 5_000);
1734
+ try {
1735
+ while (this.streaming && gen === this.streamGen) {
1736
+ const { done, value } = await reader.read();
1737
+ if (done)
1738
+ break;
1739
+ lastByteAt = Date.now();
1740
+ buf += decoder.decode(value, { stream: true });
1741
+ const { events, rest } = parseSse(buf);
1742
+ buf = rest;
1743
+ for (const ev of events)
1744
+ this.handleFrame(ev.event, ev.data);
1745
+ }
1746
+ }
1747
+ finally {
1748
+ clearInterval(watchdog);
1465
1749
  }
1466
1750
  }
1467
1751
  sleep(ms) {