@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/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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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() -
|
|
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
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
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) {
|