@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/CHANGELOG.md +101 -1
- package/README.md +29 -7
- package/dist/adapter-core.d.ts +4 -0
- package/dist/adapter-core.d.ts.map +1 -1
- package/dist/adapter-core.js +18 -6
- package/dist/adapter-core.js.map +1 -1
- package/dist/client.d.ts +118 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +302 -18
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter-core.ts +22 -6
- package/src/client.ts +380 -18
- package/src/errors.ts +3 -1
- package/src/index.ts +3 -1
package/src/client.ts
CHANGED
|
@@ -107,6 +107,31 @@ export interface CompanionClientOptions {
|
|
|
107
107
|
* configuration errors and never trigger it. Alternative: call `setToken()`
|
|
108
108
|
* proactively on your own refresh schedule. */
|
|
109
109
|
onAuthError?: () => string | null | Promise<string | null>;
|
|
110
|
+
/** Opt-in offline send queue (0.34.0). A sendText that fails at the NETWORK
|
|
111
|
+
* level (fetch rejected before any HTTP response, or `navigator.onLine` is
|
|
112
|
+
* false) is queued instead of lost, and replayed FIFO once the server is
|
|
113
|
+
* reachable again — after a successful connect(), on every stream
|
|
114
|
+
* reconnect, or via `flushOutbox()`. Replays reuse the ORIGINAL turnId:
|
|
115
|
+
* the server dedupes completed turnIds (10 min TTL, last 8 per session),
|
|
116
|
+
* so a retry after an ambiguous failure can never double-run a turn. HTTP
|
|
117
|
+
* error responses (4xx/5xx incl. 429) are NEVER queued — the server saw
|
|
118
|
+
* those; they keep the normal throw semantics. Default false. */
|
|
119
|
+
queueOffline?: boolean;
|
|
120
|
+
/** Persistence for the offline queue (see `queueOffline`). Defaults to
|
|
121
|
+
* localStorage when available (privacy-mode / quota failures fall back
|
|
122
|
+
* silently), else an in-memory page-lifetime store. The key is
|
|
123
|
+
* `pouchy-outbox:${surface}`, so each surface keeps its own queue. */
|
|
124
|
+
outboxStore?: OutboxStore;
|
|
125
|
+
/** Deadline for a request to produce RESPONSE HEADERS, in ms (0.35.0).
|
|
126
|
+
* Bounds every HTTP call the client makes (connect, sendText, recall, …) so
|
|
127
|
+
* a server that accepts the TCP connection but never answers can't hang a
|
|
128
|
+
* helper forever — previously only `sendText({ awaitReply })` had any
|
|
129
|
+
* deadline. Reading a response BODY (the SSE event stream, a streaming
|
|
130
|
+
* sendText reply) is never bounded by this — the timer is cleared the
|
|
131
|
+
* moment headers arrive. Composes with any per-call AbortSignal (whichever
|
|
132
|
+
* fires first wins); the timeout rejects as a status-0 CompanionError with
|
|
133
|
+
* code 'request_timeout'. Default 30_000; 0 disables. */
|
|
134
|
+
requestTimeoutMs?: number;
|
|
110
135
|
}
|
|
111
136
|
|
|
112
137
|
export interface HelloAck {
|
|
@@ -268,6 +293,53 @@ export interface SendTextReply {
|
|
|
268
293
|
envelope: CompanionEnvelope;
|
|
269
294
|
}
|
|
270
295
|
|
|
296
|
+
/** One send queued while offline (see the `queueOffline` option) — replayed
|
|
297
|
+
* FIFO by flushOutbox with its ORIGINAL `turnId` (the server's turn-dedup is
|
|
298
|
+
* what makes the retry safe). `at` is Date.now() at enqueue. */
|
|
299
|
+
export interface QueuedSend {
|
|
300
|
+
turnId: string;
|
|
301
|
+
text: string;
|
|
302
|
+
images?: string[];
|
|
303
|
+
at: number;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Pluggable persistence for the offline send queue (the `outboxStore`
|
|
307
|
+
* option). Both methods receive the storage key
|
|
308
|
+
* (`pouchy-outbox:${surface}`); `load` returns the previously-saved value or
|
|
309
|
+
* null. */
|
|
310
|
+
export interface OutboxStore {
|
|
311
|
+
load(key: string): string | null;
|
|
312
|
+
save(key: string, value: string): void;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Default outbox persistence: localStorage when the environment has one (and
|
|
316
|
+
// allows it — privacy mode / quota errors fall through), else a module-level
|
|
317
|
+
// Map so the queue at least survives for the page's lifetime.
|
|
318
|
+
const memoryOutbox = new Map<string, string>();
|
|
319
|
+
const defaultOutboxStore: OutboxStore = {
|
|
320
|
+
load(key) {
|
|
321
|
+
try {
|
|
322
|
+
if (typeof window !== 'undefined' && window.localStorage) {
|
|
323
|
+
return window.localStorage.getItem(key);
|
|
324
|
+
}
|
|
325
|
+
} catch {
|
|
326
|
+
/* storage denied — fall through to the in-memory copy */
|
|
327
|
+
}
|
|
328
|
+
return memoryOutbox.get(key) ?? null;
|
|
329
|
+
},
|
|
330
|
+
save(key, value) {
|
|
331
|
+
try {
|
|
332
|
+
if (typeof window !== 'undefined' && window.localStorage) {
|
|
333
|
+
window.localStorage.setItem(key, value);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
} catch {
|
|
337
|
+
/* quota / privacy mode — keep the in-memory copy below */
|
|
338
|
+
}
|
|
339
|
+
memoryOutbox.set(key, value);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
271
343
|
type Handler = (envelope: CompanionEnvelope) => void;
|
|
272
344
|
|
|
273
345
|
/** Token-streaming subscriber (P1-2). `chunk` is a raw text delta of the reply
|
|
@@ -345,6 +417,18 @@ export class CompanionClient {
|
|
|
345
417
|
// paths pay one falsy check.
|
|
346
418
|
private readonly debugSink: ((event: CompanionDebugEvent) => void) | null;
|
|
347
419
|
|
|
420
|
+
// Offline send queue (0.34.0) — null until first use, then loaded lazily
|
|
421
|
+
// from the store so a reloaded tab resumes the queue its predecessor
|
|
422
|
+
// persisted.
|
|
423
|
+
private outbox: QueuedSend[] | null = null;
|
|
424
|
+
private readonly outboxHandlers = new Set<(items: readonly QueuedSend[]) => void>();
|
|
425
|
+
// In-flight flush, shared so concurrent triggers run ONE flush at a time.
|
|
426
|
+
private outboxFlush: Promise<{ sent: number; remaining: number }> | null = null;
|
|
427
|
+
/** Epoch-ms before which flush attempts are skipped — set from a 429's
|
|
428
|
+
* Retry-After during replay so the auto-flush on every reconnect doesn't
|
|
429
|
+
* re-hammer a server that just told us to back off (0.35.0). */
|
|
430
|
+
private outboxBlockedUntil = 0;
|
|
431
|
+
|
|
348
432
|
constructor(opts: CompanionClientOptions) {
|
|
349
433
|
if (!opts.baseUrl)
|
|
350
434
|
throw new CompanionError('CompanionClient: baseUrl is required', 0, 'missing_option');
|
|
@@ -409,6 +493,9 @@ export class CompanionClient {
|
|
|
409
493
|
console.error('[companion-sdk] stream-state handler threw', e);
|
|
410
494
|
}
|
|
411
495
|
}
|
|
496
|
+
// The stream coming back up is the strongest "server reachable again"
|
|
497
|
+
// signal there is — drain any sends queued while offline (0.34.0).
|
|
498
|
+
if (next === 'connected' && this.opts.queueOffline) void this.flushOutbox().catch(() => {});
|
|
412
499
|
}
|
|
413
500
|
|
|
414
501
|
/** Swap the bearer token used by every subsequent request and stream
|
|
@@ -457,6 +544,48 @@ export class CompanionClient {
|
|
|
457
544
|
return major > 1 || (major === 1 && minor >= 1);
|
|
458
545
|
}
|
|
459
546
|
|
|
547
|
+
/** doFetch raced against the `requestTimeoutMs` HEADERS deadline (0.35.0).
|
|
548
|
+
* The deadline only bounds time-to-response-headers: it does NOT swap the
|
|
549
|
+
* request's AbortSignal, so a streaming response body stays tied to the
|
|
550
|
+
* caller's own signal, and the timer is cleared the moment the race
|
|
551
|
+
* settles. On timeout the helper rejects `request_timeout` (status 0) —
|
|
552
|
+
* code-bearing, so the offline queue's network-failure predicate correctly
|
|
553
|
+
* ignores it (the server may have received the request). The abandoned
|
|
554
|
+
* fetch keeps its rejection silenced so a late network error can't surface
|
|
555
|
+
* as an unhandled rejection. */
|
|
556
|
+
private async doFetchBounded(
|
|
557
|
+
url: string,
|
|
558
|
+
init: RequestInit & { headers: Record<string, string> }
|
|
559
|
+
): Promise<Response> {
|
|
560
|
+
const budget = this.opts.requestTimeoutMs ?? 30_000;
|
|
561
|
+
if (!budget) return this.doFetch(url, init);
|
|
562
|
+
const req = this.doFetch(url, init);
|
|
563
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
564
|
+
try {
|
|
565
|
+
return await Promise.race([
|
|
566
|
+
req,
|
|
567
|
+
new Promise<never>((_, reject) => {
|
|
568
|
+
timer = setTimeout(
|
|
569
|
+
() =>
|
|
570
|
+
reject(
|
|
571
|
+
new CompanionError(
|
|
572
|
+
`request timed out after ${budget}ms waiting for response headers`,
|
|
573
|
+
0,
|
|
574
|
+
'request_timeout'
|
|
575
|
+
)
|
|
576
|
+
),
|
|
577
|
+
budget
|
|
578
|
+
);
|
|
579
|
+
})
|
|
580
|
+
]);
|
|
581
|
+
} catch (e) {
|
|
582
|
+
req.catch(() => {});
|
|
583
|
+
throw e;
|
|
584
|
+
} finally {
|
|
585
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
460
589
|
private async fetchAuthed(
|
|
461
590
|
url: string,
|
|
462
591
|
init: RequestInit & { headers: Record<string, string> }
|
|
@@ -467,7 +596,7 @@ export class CompanionClient {
|
|
|
467
596
|
this.dbg({ kind: 'request', at: startedAt, method, path });
|
|
468
597
|
let res: Response;
|
|
469
598
|
try {
|
|
470
|
-
res = await this.
|
|
599
|
+
res = await this.doFetchBounded(url, init);
|
|
471
600
|
} catch (e) {
|
|
472
601
|
// A caller-supplied AbortSignal makes fetch throw a native AbortError —
|
|
473
602
|
// convert it at this single choke point so cancellation honors the
|
|
@@ -497,7 +626,7 @@ export class CompanionClient {
|
|
|
497
626
|
const retryAt = Date.now();
|
|
498
627
|
this.dbg({ kind: 'request', at: retryAt, method, path });
|
|
499
628
|
try {
|
|
500
|
-
const retryRes = await this.
|
|
629
|
+
const retryRes = await this.doFetchBounded(url, init);
|
|
501
630
|
this.dbg({
|
|
502
631
|
kind: 'response',
|
|
503
632
|
at: Date.now(),
|
|
@@ -616,6 +745,9 @@ export class CompanionClient {
|
|
|
616
745
|
}, opts?.signal);
|
|
617
746
|
this._session = json.session;
|
|
618
747
|
this.cursor = json.resumeCursor ?? 0;
|
|
748
|
+
// A successful handshake proves reachability — replay any sends queued
|
|
749
|
+
// while offline (0.34.0; no-op when the queue is empty).
|
|
750
|
+
if (this.opts.queueOffline) void this.flushOutbox().catch(() => {});
|
|
619
751
|
return {
|
|
620
752
|
...json,
|
|
621
753
|
representative: json.representative ?? false,
|
|
@@ -668,7 +800,15 @@ export class CompanionClient {
|
|
|
668
800
|
* can't ride the request (`stream: false`, or an old server) does it fall
|
|
669
801
|
* back to the event stream — which then DOES need start() — resolving with
|
|
670
802
|
* the next `companion.message`, or rejecting with code `reply_timeout`
|
|
671
|
-
* after `replyTimeoutMs` (default 60s).
|
|
803
|
+
* after `replyTimeoutMs` (default 60s).
|
|
804
|
+
*
|
|
805
|
+
* OFFLINE QUEUE (0.34.0, opt-in `queueOffline: true`): a send that never
|
|
806
|
+
* REACHED the server — fetch rejected at the network level, or
|
|
807
|
+
* `navigator.onLine` is already false — is queued for replay instead of
|
|
808
|
+
* lost, and resolves `{ seq: null, queued: true, turnId }`. With
|
|
809
|
+
* `awaitReply: true` there is no reply to await this session, so it
|
|
810
|
+
* rejects with code `queued_offline` (the item IS queued first). HTTP
|
|
811
|
+
* error responses keep the throw semantics above — they are never queued. */
|
|
672
812
|
async sendText(
|
|
673
813
|
text: string,
|
|
674
814
|
opts: { awaitReply: true; images?: string[]; stream?: boolean; replyTimeoutMs?: number; signal?: AbortSignal }
|
|
@@ -676,11 +816,11 @@ export class CompanionClient {
|
|
|
676
816
|
async sendText(
|
|
677
817
|
text: string,
|
|
678
818
|
opts?: { images?: string[]; stream?: boolean; awaitReply?: boolean; replyTimeoutMs?: number; signal?: AbortSignal }
|
|
679
|
-
): Promise<{ seq: number | null }>;
|
|
819
|
+
): Promise<{ seq: number | null; queued?: boolean; turnId?: string }>;
|
|
680
820
|
async sendText(
|
|
681
821
|
text: string,
|
|
682
822
|
opts?: { images?: string[]; stream?: boolean; awaitReply?: boolean; replyTimeoutMs?: number; signal?: AbortSignal }
|
|
683
|
-
): Promise<{ seq: number | null } | SendTextReply> {
|
|
823
|
+
): Promise<{ seq: number | null; queued?: boolean; turnId?: string } | SendTextReply> {
|
|
684
824
|
const id = this.requireSession();
|
|
685
825
|
// Client-minted correlation id (0.28.0): the server echoes it back as
|
|
686
826
|
// `replyTo` on this turn's companion.message — even across an app-tool
|
|
@@ -688,6 +828,29 @@ export class CompanionClient {
|
|
|
688
828
|
// message that happens by (world-state reactions, confirm outcomes).
|
|
689
829
|
const turnId = `turn_${this.randomId()}`;
|
|
690
830
|
const body = { text, turnId, ...(opts?.images?.length ? { images: opts.images } : {}) };
|
|
831
|
+
if (!this.opts.queueOffline) return this.dispatchText(id, body, opts, turnId);
|
|
832
|
+
// Offline queue: two triggers only — the browser already knows it's
|
|
833
|
+
// offline, or fetch itself rejected without an HTTP response. An HTTP
|
|
834
|
+
// error (4xx/5xx incl. 429) means the server SAW the turn: those keep
|
|
835
|
+
// the normal throw semantics and are never queued.
|
|
836
|
+
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
|
837
|
+
return this.queueSend(turnId, text, opts);
|
|
838
|
+
}
|
|
839
|
+
try {
|
|
840
|
+
return await this.dispatchText(id, body, opts, turnId);
|
|
841
|
+
} catch (e) {
|
|
842
|
+
if (this.isNetworkSendFailure(e)) return this.queueSend(turnId, text, opts);
|
|
843
|
+
throw e;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** The pre-queue dispatch of sendText: awaitReply / streaming / buffered. */
|
|
848
|
+
private async dispatchText(
|
|
849
|
+
id: string,
|
|
850
|
+
body: Record<string, unknown>,
|
|
851
|
+
opts?: { images?: string[]; stream?: boolean; awaitReply?: boolean; replyTimeoutMs?: number; signal?: AbortSignal },
|
|
852
|
+
turnId?: string
|
|
853
|
+
): Promise<{ seq: number | null } | SendTextReply> {
|
|
691
854
|
if (opts?.awaitReply) return this.sendTextAwaitingReply(id, body, opts, turnId);
|
|
692
855
|
const wantStream = opts?.stream ?? this.deltaHandlers.size > 0;
|
|
693
856
|
if (!wantStream) {
|
|
@@ -701,6 +864,42 @@ export class CompanionClient {
|
|
|
701
864
|
return this.sendTextStreaming(id, body, opts?.signal);
|
|
702
865
|
}
|
|
703
866
|
|
|
867
|
+
/** True only for a send that never reached the server: fetch rejected at
|
|
868
|
+
* the network level, which asCompanionError wraps as status 0 with NO
|
|
869
|
+
* code. Every code-carrying status-0 error is deliberate — `aborted` is
|
|
870
|
+
* the caller cancelling, `reply_timeout`/`needs_event_stream` mean the
|
|
871
|
+
* POST was accepted — and any nonzero status is a response the server
|
|
872
|
+
* produced. None of those may queue. */
|
|
873
|
+
private isNetworkSendFailure(e: unknown): boolean {
|
|
874
|
+
return e instanceof CompanionError && e.status === 0 && e.code === undefined;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Enqueue an offline send. Plain sends resolve `{ seq: null, queued: true,
|
|
878
|
+
* turnId }`; awaitReply can't await a reply that won't arrive this
|
|
879
|
+
* session, so it rejects with `queued_offline` — the item is queued
|
|
880
|
+
* either way. */
|
|
881
|
+
private queueSend(
|
|
882
|
+
turnId: string,
|
|
883
|
+
text: string,
|
|
884
|
+
opts?: { images?: string[]; awaitReply?: boolean }
|
|
885
|
+
): { seq: null; queued: true; turnId: string } {
|
|
886
|
+
this.loadOutbox().push({
|
|
887
|
+
turnId,
|
|
888
|
+
text,
|
|
889
|
+
...(opts?.images?.length ? { images: opts.images } : {}),
|
|
890
|
+
at: Date.now()
|
|
891
|
+
});
|
|
892
|
+
this.commitOutbox();
|
|
893
|
+
if (opts?.awaitReply) {
|
|
894
|
+
throw new CompanionError(
|
|
895
|
+
'offline — the text was queued and will send when the connection returns; no reply to await',
|
|
896
|
+
0,
|
|
897
|
+
'queued_offline'
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
return { seq: null, queued: true, turnId };
|
|
901
|
+
}
|
|
902
|
+
|
|
704
903
|
/** The awaitReply leg of sendText. Prefers the in-request answer (the
|
|
705
904
|
* streaming `done` frame's envelope); falls back to the next
|
|
706
905
|
* `companion.message` off the event stream, bounded by a timeout. The
|
|
@@ -713,6 +912,12 @@ export class CompanionClient {
|
|
|
713
912
|
turnId?: string
|
|
714
913
|
): Promise<SendTextReply> {
|
|
715
914
|
const timeoutMs = opts?.replyTimeoutMs ?? 60_000;
|
|
915
|
+
// Already-aborted fast-fail comes FIRST (0.35.0): it used to sit after the
|
|
916
|
+
// companion.message subscription below, whose cleanup lives in the finally
|
|
917
|
+
// of the try we never entered — every call with a spent signal leaked one
|
|
918
|
+
// permanent handler (a host reusing one AbortController across turns
|
|
919
|
+
// accumulated them without bound).
|
|
920
|
+
if (opts?.signal?.aborted) throw new CompanionError('request aborted', 0, 'aborted');
|
|
716
921
|
let resolveFallback!: (env: CompanionEnvelope) => void;
|
|
717
922
|
const fallback = new Promise<CompanionEnvelope>((res) => (resolveFallback = res));
|
|
718
923
|
// Correlate: resolve only on THIS turn's reply (`replyTo === turnId`).
|
|
@@ -731,10 +936,9 @@ export class CompanionClient {
|
|
|
731
936
|
const ac = new AbortController();
|
|
732
937
|
// Link the caller's signal: aborting it tears down the in-flight stream
|
|
733
938
|
// (via ac) AND rejects the whole operation with `aborted`, exactly like
|
|
734
|
-
// the deadline does.
|
|
939
|
+
// the deadline does.
|
|
735
940
|
let onCallerAbort: (() => void) | undefined;
|
|
736
941
|
if (opts?.signal) {
|
|
737
|
-
if (opts.signal.aborted) throw new CompanionError('request aborted', 0, 'aborted');
|
|
738
942
|
onCallerAbort = () => ac.abort();
|
|
739
943
|
opts.signal.addEventListener('abort', onCallerAbort, { once: true });
|
|
740
944
|
}
|
|
@@ -923,10 +1127,139 @@ export class CompanionClient {
|
|
|
923
1127
|
} catch {
|
|
924
1128
|
/* stream already finished */
|
|
925
1129
|
}
|
|
926
|
-
|
|
1130
|
+
// Normalize (0.35.0): a mid-stream network drop rejects reader.read()
|
|
1131
|
+
// with a NATIVE TypeError. Rethrowing it raw broke the "every helper
|
|
1132
|
+
// rejects with CompanionError" contract AND slipped past the offline
|
|
1133
|
+
// queue's isNetworkSendFailure (which requires a code-less status-0
|
|
1134
|
+
// CompanionError) — the exact loss queueOffline exists to prevent.
|
|
1135
|
+
// Queueing the replay is safe: it reuses the ORIGINAL turnId and the
|
|
1136
|
+
// server dedupes completed turns, so a turn that did run isn't re-run.
|
|
1137
|
+
throw this.asCompanionError(e);
|
|
927
1138
|
}
|
|
928
1139
|
}
|
|
929
1140
|
|
|
1141
|
+
/** The live offline queue, loaded from the store on first use so a reloaded
|
|
1142
|
+
* tab resumes what its predecessor persisted. Unreadable or foreign store
|
|
1143
|
+
* content is discarded — a corrupt store must never break sending. */
|
|
1144
|
+
private loadOutbox(): QueuedSend[] {
|
|
1145
|
+
if (this.outbox) return this.outbox;
|
|
1146
|
+
let items: QueuedSend[] = [];
|
|
1147
|
+
try {
|
|
1148
|
+
const raw = (this.opts.outboxStore ?? defaultOutboxStore).load(this.outboxKey());
|
|
1149
|
+
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
|
|
1150
|
+
if (Array.isArray(parsed)) {
|
|
1151
|
+
items = parsed.filter(
|
|
1152
|
+
(q): q is QueuedSend =>
|
|
1153
|
+
!!q &&
|
|
1154
|
+
typeof (q as QueuedSend).turnId === 'string' &&
|
|
1155
|
+
typeof (q as QueuedSend).text === 'string'
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
} catch {
|
|
1159
|
+
/* unreadable store — start empty */
|
|
1160
|
+
}
|
|
1161
|
+
this.outbox = items;
|
|
1162
|
+
return items;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
private outboxKey(): string {
|
|
1166
|
+
return `pouchy-outbox:${this.opts.surface ?? 'default'}`;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** Persist + notify after EVERY queue mutation (enqueue / flushed / drop),
|
|
1170
|
+
* so the stored copy never lags what a reload would need. Handler
|
|
1171
|
+
* isolation matches emit(): a throwing consumer must not skip the rest. */
|
|
1172
|
+
private commitOutbox(): void {
|
|
1173
|
+
const items = this.loadOutbox();
|
|
1174
|
+
try {
|
|
1175
|
+
(this.opts.outboxStore ?? defaultOutboxStore).save(this.outboxKey(), JSON.stringify(items));
|
|
1176
|
+
} catch {
|
|
1177
|
+
/* store save failed — the in-memory queue still flushes this page */
|
|
1178
|
+
}
|
|
1179
|
+
const copy = Object.freeze([...items]) as readonly QueuedSend[];
|
|
1180
|
+
for (const h of this.outboxHandlers) {
|
|
1181
|
+
try {
|
|
1182
|
+
h(copy);
|
|
1183
|
+
} catch (e) {
|
|
1184
|
+
console.error('[companion-sdk] outbox handler threw', e);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** The sends queued while offline (oldest first), as a defensive copy —
|
|
1190
|
+
* see the `queueOffline` option. */
|
|
1191
|
+
pendingOutbox(): readonly QueuedSend[] {
|
|
1192
|
+
return [...this.loadOutbox()];
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/** Subscribe to offline-queue changes (enqueue / flushed / dropped) — drive
|
|
1196
|
+
* a "N pending" badge from `items.length`. Returns an unsubscribe fn. */
|
|
1197
|
+
onOutboxChange(handler: (items: readonly QueuedSend[]) => void): () => void {
|
|
1198
|
+
this.outboxHandlers.add(handler);
|
|
1199
|
+
return () => this.outboxHandlers.delete(handler);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/** Replay the offline queue: FIFO, sequential, each item a normal buffered
|
|
1203
|
+
* input POST reusing its ORIGINAL `turnId` (the server dedupes completed
|
|
1204
|
+
* turnIds — see `queueOffline` — so a retry can never double-run a turn).
|
|
1205
|
+
* Runs automatically after connect() and on every stream reconnect; call
|
|
1206
|
+
* it yourself for a manual retry affordance. One flush at a time —
|
|
1207
|
+
* concurrent calls share the in-flight run. Per item: success (or a
|
|
1208
|
+
* server-side duplicate) → removed; network failure / 5xx / 429 → stop,
|
|
1209
|
+
* keep this item and the rest for the next flush; any other 4xx → the
|
|
1210
|
+
* item itself is invalid (the server deliberately rejected it) — drop it,
|
|
1211
|
+
* console.warn, and keep flushing. */
|
|
1212
|
+
async flushOutbox(): Promise<{ sent: number; remaining: number }> {
|
|
1213
|
+
this.requireSession();
|
|
1214
|
+
this.outboxFlush ??= this.runOutboxFlush().finally(() => {
|
|
1215
|
+
this.outboxFlush = null;
|
|
1216
|
+
});
|
|
1217
|
+
return this.outboxFlush;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
private async runOutboxFlush(): Promise<{ sent: number; remaining: number }> {
|
|
1221
|
+
const id = this.requireSession();
|
|
1222
|
+
const items = this.loadOutbox();
|
|
1223
|
+
// Respect the last replay 429's Retry-After (0.35.0): the flush re-arms
|
|
1224
|
+
// on EVERY stream reconnect, so without this gate a rate-limited server
|
|
1225
|
+
// got re-hammered on each reconnect despite naming its own window.
|
|
1226
|
+
if (Date.now() < this.outboxBlockedUntil) return { sent: 0, remaining: items.length };
|
|
1227
|
+
let sent = 0;
|
|
1228
|
+
while (items.length) {
|
|
1229
|
+
const item = items[0];
|
|
1230
|
+
try {
|
|
1231
|
+
await this.postJson<{ seq: number | null; duplicate?: boolean }>(
|
|
1232
|
+
`/api/companion/session/${id}/input`,
|
|
1233
|
+
{
|
|
1234
|
+
text: item.text,
|
|
1235
|
+
turnId: item.turnId,
|
|
1236
|
+
...(item.images?.length ? { images: item.images } : {})
|
|
1237
|
+
}
|
|
1238
|
+
);
|
|
1239
|
+
} catch (e) {
|
|
1240
|
+
const status = e instanceof CompanionError ? e.status : 0;
|
|
1241
|
+
if (status >= 400 && status < 500 && status !== 429) {
|
|
1242
|
+
// The server saw it and said no — retrying can't fix the item.
|
|
1243
|
+
console.warn('[companion-sdk] dropping queued send the server rejected', status, e);
|
|
1244
|
+
items.shift();
|
|
1245
|
+
this.commitOutbox();
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
// A throttle names its own retry window — honor it across flushes.
|
|
1249
|
+
if (status === 429 && e instanceof CompanionError && typeof e.retryAfter === 'number') {
|
|
1250
|
+
this.outboxBlockedUntil = Date.now() + e.retryAfter * 1000;
|
|
1251
|
+
}
|
|
1252
|
+
// Unreachable again (or throttled / a server-side failure): keep
|
|
1253
|
+
// this item and everything behind it for the next flush.
|
|
1254
|
+
break;
|
|
1255
|
+
}
|
|
1256
|
+
items.shift();
|
|
1257
|
+
sent += 1;
|
|
1258
|
+
this.commitOutbox();
|
|
1259
|
+
}
|
|
1260
|
+
return { sent, remaining: items.length };
|
|
1261
|
+
}
|
|
1262
|
+
|
|
930
1263
|
/** Subscribe to token-streaming deltas of the reply (P1-2). Registering a
|
|
931
1264
|
* subscriber makes sendText stream automatically. Returns an unsubscribe
|
|
932
1265
|
* function. Render chunks as they arrive; on `meta.reset` clear the partial;
|
|
@@ -1256,7 +1589,10 @@ export class CompanionClient {
|
|
|
1256
1589
|
* arrive as `control.error`. Since 0.30.0 `code` is typed as
|
|
1257
1590
|
* `ControlErrorCodeValue` (the CONTROL_ERROR_CODES vocabulary + the
|
|
1258
1591
|
* SDK-synthesized stream codes, open for newer servers) so a `switch` on it
|
|
1259
|
-
* autocompletes.
|
|
1592
|
+
* autocompletes. A malformed frame with no string `code` is delivered with
|
|
1593
|
+
* the literal fallback `'error'` (not part of the exported vocabulary) —
|
|
1594
|
+
* give your `switch` a `default:` arm rather than enumerating every code.
|
|
1595
|
+
* Returns an unsubscribe fn. */
|
|
1260
1596
|
onError(
|
|
1261
1597
|
handler: (
|
|
1262
1598
|
err: { code: ControlErrorCodeValue; message: string },
|
|
@@ -1713,13 +2049,19 @@ export class CompanionClient {
|
|
|
1713
2049
|
let backoff = 500;
|
|
1714
2050
|
while (this.streaming && gen === this.streamGen) {
|
|
1715
2051
|
let errored = false;
|
|
1716
|
-
|
|
2052
|
+
// Stamped in onopen (0.35.0) — measuring from before the constructor
|
|
2053
|
+
// counted DNS/TCP/handshake time as "connection lifetime", so a gateway
|
|
2054
|
+
// that took ~2s to accept and then instantly closed read as long-lived
|
|
2055
|
+
// and RESET the backoff (a near-hot reconnect loop). 0 = never opened,
|
|
2056
|
+
// which always backs off.
|
|
2057
|
+
let openedAt = 0;
|
|
1717
2058
|
try {
|
|
1718
2059
|
await new Promise<void>((resolve, reject) => {
|
|
1719
2060
|
const ws = new WS(deriveWsStreamUrl(this.opts.baseUrl, id, this.opts.token, this.cursor));
|
|
1720
2061
|
let poll: ReturnType<typeof setInterval> | undefined;
|
|
1721
2062
|
let settled = false;
|
|
1722
2063
|
ws.onopen = () => {
|
|
2064
|
+
openedAt = Date.now();
|
|
1723
2065
|
if (this.streaming && gen === this.streamGen) this.setStreamState('connected');
|
|
1724
2066
|
};
|
|
1725
2067
|
const settle = (err?: unknown) => {
|
|
@@ -1772,7 +2114,7 @@ export class CompanionClient {
|
|
|
1772
2114
|
// back off like the SSE loop, or this reconnects in a zero-delay hot
|
|
1773
2115
|
// loop. A connection that lived a while resets the backoff.
|
|
1774
2116
|
if (this.streaming && gen === this.streamGen) this.setStreamState('reconnecting');
|
|
1775
|
-
if (Date.now() -
|
|
2117
|
+
if (openedAt === 0 || Date.now() - openedAt < 2000) {
|
|
1776
2118
|
await this.backoffSleep(backoff);
|
|
1777
2119
|
backoff = Math.min(backoff * 2, 8000);
|
|
1778
2120
|
} else {
|
|
@@ -1937,13 +2279,33 @@ export class CompanionClient {
|
|
|
1937
2279
|
const reader = body.getReader();
|
|
1938
2280
|
const decoder = new TextDecoder();
|
|
1939
2281
|
let buf = '';
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
2282
|
+
// Liveness watchdog (0.35.0). The server writes a `: ping` comment every
|
|
2283
|
+
// ~1.5s and closes each stream window at 45s — a healthy connection is
|
|
2284
|
+
// NEVER byte-silent for long. On a silent half-open TCP drop (NAT/LB
|
|
2285
|
+
// idle-kill, laptop sleep) no FIN ever arrives: reader.read() blocks for
|
|
2286
|
+
// minutes while streamState still reads 'connected' and every reply is
|
|
2287
|
+
// silently lost. 10× the ping interval without a single byte can only be
|
|
2288
|
+
// a dead connection — cancel the reader so read() returns and the loop
|
|
2289
|
+
// reconnects through its normal backoff + cursor resume (replays dedupe
|
|
2290
|
+
// by envelope id). Only the SSE plane gets this: the WS plane has no
|
|
2291
|
+
// heartbeat contract, so an idle-but-healthy socket can't be told apart.
|
|
2292
|
+
const SSE_STALL_MS = 15_000;
|
|
2293
|
+
let lastByteAt = Date.now();
|
|
2294
|
+
const watchdog = setInterval(() => {
|
|
2295
|
+
if (Date.now() - lastByteAt > SSE_STALL_MS) void reader.cancel().catch(() => {});
|
|
2296
|
+
}, 5_000);
|
|
2297
|
+
try {
|
|
2298
|
+
while (this.streaming && gen === this.streamGen) {
|
|
2299
|
+
const { done, value } = await reader.read();
|
|
2300
|
+
if (done) break;
|
|
2301
|
+
lastByteAt = Date.now();
|
|
2302
|
+
buf += decoder.decode(value, { stream: true });
|
|
2303
|
+
const { events, rest } = parseSse(buf);
|
|
2304
|
+
buf = rest;
|
|
2305
|
+
for (const ev of events) this.handleFrame(ev.event, ev.data);
|
|
2306
|
+
}
|
|
2307
|
+
} finally {
|
|
2308
|
+
clearInterval(watchdog);
|
|
1947
2309
|
}
|
|
1948
2310
|
}
|
|
1949
2311
|
|
package/src/errors.ts
CHANGED
|
@@ -20,7 +20,9 @@ export const SDK_SYNTHESIZED_ERROR_CODES = [
|
|
|
20
20
|
'call_unsupported', // startCall on a server/provider without voice
|
|
21
21
|
'call_connect_failed', // WebRTC/provider handshake failed
|
|
22
22
|
'call_dependency_missing', // optional voice dependency not installed
|
|
23
|
-
'aborted' // a caller-supplied AbortSignal cancelled the request (0.29.0)
|
|
23
|
+
'aborted', // a caller-supplied AbortSignal cancelled the request (0.29.0)
|
|
24
|
+
'queued_offline', // queueOffline: the text was queued while offline and will send on reconnect (0.34.0)
|
|
25
|
+
'request_timeout' // requestTimeoutMs elapsed before response headers arrived (0.35.0)
|
|
24
26
|
] as const;
|
|
25
27
|
export type SdkSynthesizedErrorCode = (typeof SDK_SYNTHESIZED_ERROR_CODES)[number];
|
|
26
28
|
|