@opinionated-machine/sse-fallback 0.1.1

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.
@@ -0,0 +1,878 @@
1
+ import { createSSEStreamParser } from '@opinionated-machine/sse-parser';
2
+ import { DEFAULT_POLICY } from "./bindingTypes.js";
3
+ import { Reconciler } from "./reconciler.js";
4
+ import { backoffDelay, ResettableTimer, sleep } from "./scheduler.js";
5
+ import { isParsedStreamResponse } from "./transport.js";
6
+ /**
7
+ * Rejection thrown by `waitFor` / `waitForTerminal` when the subscription
8
+ * stops before the awaited event arrives. Carries the stop reason so the
9
+ * caller can branch without inspecting the message.
10
+ */
11
+ export class SubscriptionStoppedError extends Error {
12
+ reason;
13
+ status;
14
+ limit;
15
+ channel;
16
+ constructor(detail) {
17
+ super(`Subscription stopped (${detail.reason}) before the awaited event arrived`);
18
+ this.name = 'SubscriptionStoppedError';
19
+ this.reason = detail.reason;
20
+ this.status = detail.status;
21
+ this.limit = detail.limit;
22
+ this.channel = detail.channel;
23
+ }
24
+ }
25
+ class ResilientSubscriptionImpl {
26
+ binding;
27
+ transport;
28
+ params;
29
+ policy;
30
+ diagnostics;
31
+ random;
32
+ parseEventData;
33
+ reconciler;
34
+ pollGate;
35
+ onAuthChallenge;
36
+ abortController = new AbortController();
37
+ currentStreamAbort;
38
+ statusValue = 'connecting';
39
+ stopped = false;
40
+ stopDetail;
41
+ streamConnected = false;
42
+ lastEventId;
43
+ /**
44
+ * The credential refresh currently running, shared by both channels so a
45
+ * poll and a reconnect refused by the same expired token recover together.
46
+ */
47
+ authRefresh;
48
+ serverRetryHintMs;
49
+ consecutiveConnectFailures = 0;
50
+ degraded = false;
51
+ /** Whether the current stream has produced any bytes at all. */
52
+ streamProducedBytes = false;
53
+ pollInFlight = false;
54
+ pollQueued = false;
55
+ pollFailures = 0;
56
+ idlePolls = 0;
57
+ /** Polls attempted, for `subscriptionBudget.maxPolls`. */
58
+ pollsAttempted = 0;
59
+ /** Whether the one auth retry has been spent since the last successful request. */
60
+ authRetrySpent = false;
61
+ deadman = new ResettableTimer(() => this.schedulePoll());
62
+ staleConnection = new ResettableTimer(() => this.onStaleConnection());
63
+ budgetTimer = new ResettableTimer(() => this.stopWith({ reason: 'budget-exhausted', limit: 'maxDurationMs' }));
64
+ eventListeners = new Set();
65
+ stateListeners = new Set();
66
+ statusListeners = new Set();
67
+ stopListeners = new Set();
68
+ iteratorFeeds = new Set();
69
+ constructor(binding, options) {
70
+ this.binding = binding;
71
+ this.transport = options.transport;
72
+ this.params = options.params ?? {};
73
+ this.policy = { ...DEFAULT_POLICY, ...binding.config.policy, ...options.policy };
74
+ this.diagnostics = options.diagnostics ?? {};
75
+ this.random = options.random ?? Math.random;
76
+ this.parseEventData = options.parseEventData ?? JSON.parse;
77
+ this.pollGate = options.pollGate;
78
+ this.onAuthChallenge = options.onAuthChallenge;
79
+ this.reconciler = new Reconciler(binding.config, {
80
+ hydrationBufferLimit: this.policy.hydrationBufferLimit,
81
+ onInvalidVersion: (info) => {
82
+ try {
83
+ this.diagnostics.onInvalidVersion?.(info);
84
+ }
85
+ catch (error) {
86
+ this.diagnostics.onListenerError?.(error);
87
+ }
88
+ },
89
+ });
90
+ if (options.signal) {
91
+ if (options.signal.aborted) {
92
+ this.stop();
93
+ return;
94
+ }
95
+ options.signal.addEventListener('abort', () => this.stop(), { once: true });
96
+ }
97
+ const maxDurationMs = this.policy.subscriptionBudget?.maxDurationMs;
98
+ if (maxDurationMs !== undefined) {
99
+ this.budgetTimer.arm(maxDurationMs);
100
+ }
101
+ if (this.policy.mode === 'poll-only') {
102
+ // No stream is ever opened, so the machine starts where a dual-mode
103
+ // subscription only lands after repeated connect failures.
104
+ this.degraded = true;
105
+ this.setStatus('polling');
106
+ if (this.policy.initialPoll === 'eager') {
107
+ this.schedulePoll();
108
+ }
109
+ else {
110
+ this.armDeadman();
111
+ }
112
+ return;
113
+ }
114
+ void this.runStreamLoop();
115
+ if (this.policy.initialPoll !== 'eager') {
116
+ // No hydration poll — the deadman provides the fallback cadence.
117
+ this.armDeadman();
118
+ }
119
+ }
120
+ // --------------------------------------------------------------------
121
+ // Public surface
122
+ // --------------------------------------------------------------------
123
+ get status() {
124
+ return this.statusValue;
125
+ }
126
+ get result() {
127
+ return this.stopDetail;
128
+ }
129
+ getState() {
130
+ return this.reconciler.getState();
131
+ }
132
+ onEvent(listener) {
133
+ this.eventListeners.add(listener);
134
+ return () => this.eventListeners.delete(listener);
135
+ }
136
+ onStateChange(listener) {
137
+ this.stateListeners.add(listener);
138
+ return () => this.stateListeners.delete(listener);
139
+ }
140
+ onStatusChange(listener) {
141
+ this.statusListeners.add(listener);
142
+ return () => this.statusListeners.delete(listener);
143
+ }
144
+ onStop(listener) {
145
+ if (this.stopDetail !== undefined) {
146
+ this.runListener(listener, this.stopDetail);
147
+ return () => { };
148
+ }
149
+ this.stopListeners.add(listener);
150
+ return () => this.stopListeners.delete(listener);
151
+ }
152
+ events() {
153
+ const queue = [];
154
+ let notify;
155
+ let done = this.stopped;
156
+ const feed = {
157
+ push: (event) => {
158
+ queue.push(event);
159
+ notify?.();
160
+ },
161
+ finish: () => {
162
+ done = true;
163
+ notify?.();
164
+ },
165
+ };
166
+ this.iteratorFeeds.add(feed);
167
+ const feeds = this.iteratorFeeds;
168
+ return {
169
+ [Symbol.asyncIterator]() {
170
+ return {
171
+ async next() {
172
+ while (true) {
173
+ const item = queue.shift();
174
+ if (item !== undefined)
175
+ return { value: item, done: false };
176
+ if (done) {
177
+ feeds.delete(feed);
178
+ return { value: undefined, done: true };
179
+ }
180
+ await new Promise((resolve) => {
181
+ notify = resolve;
182
+ });
183
+ notify = undefined;
184
+ }
185
+ },
186
+ return() {
187
+ feeds.delete(feed);
188
+ done = true;
189
+ return Promise.resolve({ value: undefined, done: true });
190
+ },
191
+ };
192
+ },
193
+ };
194
+ }
195
+ waitFor(event, opts) {
196
+ return this.waitMatching((delivered) => delivered.event === event, opts).then((delivered) => delivered.data);
197
+ }
198
+ waitForTerminal(opts) {
199
+ const terminal = new Set(this.binding.config.terminalEvents ?? []);
200
+ return this.waitMatching((delivered) => terminal.has(delivered.event), opts);
201
+ }
202
+ nudge() {
203
+ if (this.stopped)
204
+ return;
205
+ this.schedulePoll();
206
+ // If the stream looks dead, force a reconnect check too.
207
+ if (this.streamConnected && this.policy.staleConnectionTimeoutMs !== 'off') {
208
+ // Byte-activity watchdog stays authoritative; nothing else to do here.
209
+ return;
210
+ }
211
+ }
212
+ stop() {
213
+ this.stopWith({ reason: 'manual' });
214
+ }
215
+ stopWith(detail) {
216
+ if (this.stopped)
217
+ return;
218
+ this.stopped = true;
219
+ this.stopDetail = detail;
220
+ this.deadman.clear();
221
+ this.staleConnection.clear();
222
+ this.budgetTimer.clear();
223
+ this.currentStreamAbort?.abort();
224
+ this.abortController.abort();
225
+ this.setStatus('stopped', detail);
226
+ for (const listener of this.stopListeners)
227
+ this.runListener(listener, detail);
228
+ this.stopListeners.clear();
229
+ for (const feed of this.iteratorFeeds)
230
+ feed.finish();
231
+ this.iteratorFeeds.clear();
232
+ }
233
+ // --------------------------------------------------------------------
234
+ // Stream loop
235
+ // --------------------------------------------------------------------
236
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the connect/consume/reconnect loop is a single state machine — splitting it would obscure the transitions
237
+ async runStreamLoop() {
238
+ let firstConnect = true;
239
+ while (!this.stopped) {
240
+ const streamAbort = new AbortController();
241
+ this.currentStreamAbort = streamAbort;
242
+ const onMasterAbort = () => streamAbort.abort();
243
+ this.abortController.signal.addEventListener('abort', onMasterAbort, { once: true });
244
+ let connectFailed = false;
245
+ this.streamProducedBytes = false;
246
+ // A connect that never produces headers must not park the subscription:
247
+ // bound it here so it fails like any other connect failure (backoff,
248
+ // degradation, fallback polling) instead of hanging forever.
249
+ const connectTimeout = new ResettableTimer(() => streamAbort.abort());
250
+ if (this.policy.connectTimeoutMs !== 'off') {
251
+ connectTimeout.arm(this.policy.connectTimeoutMs);
252
+ }
253
+ try {
254
+ const response = await this.transport.openStream(this.binding.buildStreamRequest(this.params), { signal: streamAbort.signal, lastEventId: this.lastEventId });
255
+ connectTimeout.clear();
256
+ if (this.stopped)
257
+ return;
258
+ const contentType = response.headers['content-type'] ?? '';
259
+ if (response.status !== 200 ||
260
+ (contentType && !contentType.includes('text/event-stream'))) {
261
+ connectFailed = true;
262
+ // Abort the request so the rejected response body is released — the
263
+ // chunks of a refused connect are never consumed, and without this
264
+ // every retry would leak a socket.
265
+ streamAbort.abort();
266
+ this.diagnostics.onStreamError?.(new Error(`SSE connect failed with status ${response.status}`));
267
+ if (this.policy.unretryableStatuses.includes(response.status)) {
268
+ // An expired token is the common case behind a 401 in a SPA, and
269
+ // recovering without a page reload is the point of this package.
270
+ // Give the application one chance to refresh credentials; the
271
+ // reconnect below then retries with them.
272
+ const recovered = await this.tryAuthChallenge(response.status, 'stream');
273
+ if (this.stopped)
274
+ return;
275
+ if (!recovered) {
276
+ this.stopWith({
277
+ reason: 'unretryable-status',
278
+ status: response.status,
279
+ channel: 'stream',
280
+ });
281
+ return;
282
+ }
283
+ }
284
+ }
285
+ else {
286
+ this.serverRetryHintMs = undefined;
287
+ if (firstConnect) {
288
+ if (this.policy.initialPoll === 'eager') {
289
+ // Subscribe-first hydration: buffer live events until the
290
+ // snapshot lands — zero missed-event window.
291
+ this.reconciler.beginHydration();
292
+ this.schedulePoll();
293
+ }
294
+ else {
295
+ this.setStatus('live');
296
+ }
297
+ }
298
+ else {
299
+ // While degraded, an accepted connect is not evidence of anything:
300
+ // only bytes downgrade the status back out of 'polling'.
301
+ if (!this.degraded) {
302
+ this.setStatus('live');
303
+ }
304
+ if ((this.binding.config.replay ?? 'untrusted') === 'untrusted') {
305
+ // Events during the outage are lost unless the server replays
306
+ // them completely — reconcile via a poll.
307
+ this.schedulePoll();
308
+ }
309
+ }
310
+ firstConnect = false;
311
+ this.armStaleConnection();
312
+ await this.consumeStream(response);
313
+ connectTimeout.clear();
314
+ if (this.stopped || this.reconciler.isTerminated)
315
+ return;
316
+ // Stream ended (server close, stale-abort, or network) — fall
317
+ // through to the reconnect path.
318
+ }
319
+ }
320
+ catch (error) {
321
+ if (this.stopped)
322
+ return;
323
+ connectFailed = !this.streamConnected;
324
+ this.diagnostics.onStreamError?.(error);
325
+ }
326
+ finally {
327
+ connectTimeout.clear();
328
+ this.staleConnection.clear();
329
+ this.streamConnected = false;
330
+ this.abortController.signal.removeEventListener('abort', onMasterAbort);
331
+ }
332
+ if (this.stopped || this.reconciler.isTerminated)
333
+ return;
334
+ // A connection only counts as successful once it has actually carried
335
+ // bytes. A stream that is accepted and then closes immediately would
336
+ // otherwise reset the backoff on every attempt and never degrade,
337
+ // turning a broken upstream into a reconnect-and-poll storm.
338
+ if (connectFailed || !this.streamProducedBytes) {
339
+ this.consecutiveConnectFailures += 1;
340
+ }
341
+ if (this.consecutiveConnectFailures >= this.policy.degradedAfterFailures) {
342
+ if (!this.degraded) {
343
+ this.degraded = true;
344
+ this.setStatus('polling');
345
+ // Degraded cadence applies from the next deadman arm.
346
+ this.armDeadman();
347
+ }
348
+ }
349
+ else {
350
+ this.setStatus('reconnecting');
351
+ }
352
+ // The stream just dropped — data may have been lost; poll now.
353
+ this.schedulePoll();
354
+ const backoffConfig = this.degraded
355
+ ? { ...this.policy.sseRetryBackoff, maxMs: this.policy.degradedSseRetryMaxMs }
356
+ : this.policy.sseRetryBackoff;
357
+ const delay = this.serverRetryHintMs !== undefined && !connectFailed
358
+ ? this.serverRetryHintMs
359
+ : backoffDelay(backoffConfig, this.consecutiveConnectFailures, this.random);
360
+ const proceed = await sleep(delay, this.abortController.signal);
361
+ if (!proceed)
362
+ return;
363
+ }
364
+ }
365
+ consumeStream(response) {
366
+ this.streamConnected = true;
367
+ return isParsedStreamResponse(response)
368
+ ? this.consumeFrames(response.events)
369
+ : this.consumeChunks(response.chunks);
370
+ }
371
+ /** Whether the loop should stop pulling from the stream. */
372
+ get streamLoopDone() {
373
+ return this.stopped || this.reconciler.isTerminated;
374
+ }
375
+ /**
376
+ * Consume a transport that already framed the stream. Comment/heartbeat
377
+ * frames were consumed before they reached us, so liveness here is
378
+ * event-level, not byte-level — see `ParsedStreamResponse` for what that
379
+ * costs.
380
+ */
381
+ async consumeFrames(frames) {
382
+ for await (const frame of frames) {
383
+ if (this.streamLoopDone)
384
+ return;
385
+ this.onStreamActivity();
386
+ void this.handleParsedEvent(frame);
387
+ if (this.streamLoopDone)
388
+ return;
389
+ }
390
+ }
391
+ /** Consume raw text and do the SSE framing here. */
392
+ async consumeChunks(chunks) {
393
+ // The parser owns the partial-frame buffer and its own cursor, which is
394
+ // NOT `this.lastEventId`: an event whose data fails to parse holds that
395
+ // one back, and the parser knows nothing about that.
396
+ const parser = createSSEStreamParser({ lastEventId: this.lastEventId });
397
+ for await (const chunk of chunks) {
398
+ if (this.streamLoopDone)
399
+ return;
400
+ // ANY bytes (heartbeat comments included) prove transport liveness.
401
+ this.onStreamActivity();
402
+ let cursorHeld = false;
403
+ for (const event of parser.push(chunk)) {
404
+ if (!this.handleParsedEvent(event))
405
+ cursorHeld = true;
406
+ if (this.streamLoopDone)
407
+ return;
408
+ }
409
+ // A `retry:` frame carrying no data dispatches no event, so the hint
410
+ // comes from the parser rather than from the events it emitted. The
411
+ // parser value is sticky and the clamp is idempotent, so re-reading it
412
+ // per chunk costs nothing.
413
+ if (parser.retry !== undefined)
414
+ this.applyRetryHint(parser.retry);
415
+ // An `id:` frame carrying no data still moves the reconnect cursor, and
416
+ // an empty `id:` clears it, so the cursor comes from the parser rather
417
+ // than from the events it emitted. A frame this batch could not read
418
+ // holds it where it was.
419
+ if (!cursorHeld)
420
+ this.lastEventId = parser.lastEventId;
421
+ }
422
+ }
423
+ /** Called for every unit of stream activity — a raw chunk or a parsed frame. */
424
+ onStreamActivity() {
425
+ this.armStaleConnection();
426
+ if (this.streamProducedBytes)
427
+ return;
428
+ this.streamProducedBytes = true;
429
+ // The stream is demonstrably working — clear the failure history and
430
+ // leave degraded mode. Doing this here rather than at connect keeps a
431
+ // connect-then-close loop counted as the failure it is.
432
+ this.consecutiveConnectFailures = 0;
433
+ this.authRetrySpent = false;
434
+ this.degraded = false;
435
+ // Bytes on the wire are the only evidence that delivery works, so they are
436
+ // what promotes the subscription to 'live' — from degraded polling, and
437
+ // equally from the 'connecting' a byte-less stream is parked in after
438
+ // hydration was abandoned. Hydration itself still owns the status until it
439
+ // finishes, so a busy stream cannot announce 'live' before the snapshot
440
+ // it is buffering behind has landed.
441
+ if (!this.reconciler.isHydrating) {
442
+ this.setStatus('live');
443
+ }
444
+ }
445
+ /**
446
+ * Move the reconnect cursor to where this frame leaves it.
447
+ *
448
+ * The parser tracks Last-Event-ID across frames, so an event with no `id:`
449
+ * of its own still reports the cursor it inherited and an empty `id:` clears
450
+ * it. A transport that framed the stream itself reports no cursor, so there
451
+ * the id the frame carried is all there is.
452
+ */
453
+ advanceEventCursor(parsed) {
454
+ if (parsed.lastEventId !== undefined) {
455
+ this.lastEventId = parsed.lastEventId;
456
+ return;
457
+ }
458
+ if (parsed.id !== undefined && parsed.id !== '')
459
+ this.lastEventId = parsed.id;
460
+ }
461
+ /**
462
+ * Feed one framed SSE event through the reconciler.
463
+ *
464
+ * @returns `false` when the frame was unreadable, so the caller keeps the
465
+ * reconnect cursor where it was.
466
+ */
467
+ handleParsedEvent(parsed) {
468
+ if (parsed.retry !== undefined)
469
+ this.applyRetryHint(parsed.retry);
470
+ let data;
471
+ try {
472
+ data = this.parseEventData(parsed.data);
473
+ }
474
+ catch (error) {
475
+ // The frame is unreadable, so the event it carried is lost. Poll to
476
+ // repair it, and leave `lastEventId` where it was — advancing past an
477
+ // event that was never delivered would make a Last-Event-ID replay skip
478
+ // it for good.
479
+ this.diagnostics.onStreamError?.(error);
480
+ this.schedulePoll();
481
+ return false;
482
+ }
483
+ this.advanceEventCursor(parsed);
484
+ const outcome = this.reconciler.handleEvent({
485
+ event: parsed.event ?? 'message',
486
+ data,
487
+ ...(parsed.id !== undefined ? { id: parsed.id } : {}),
488
+ });
489
+ // Only an event the reconciler actually delivered pushes the
490
+ // reconciliation poll out, which is why this runs after the version gate
491
+ // and not before it. A duplicate below the watermark carries no news, and
492
+ // a flood of them would otherwise hold the poll off forever; an event
493
+ // buffered during hydration delivers nothing either, which keeps the
494
+ // deadman working as the retry timer for the snapshot it is waiting on.
495
+ //
496
+ // The idle backoff level survives on purpose. A healthily delivering
497
+ // stream needs LESS reconciliation, not more, and resetting `idlePolls`
498
+ // here pinned the interval at `deadmanDelayMs`: a stream with an event
499
+ // every 15s polled between nearly every pair of events, forever, while a
500
+ // fully idle one backed off to `deadmanIdleBackoff.maxMs`. A poll that
501
+ // does find news still resets it, in `executePoll`.
502
+ if (outcome.deliveries.length > 0)
503
+ this.armDeadman();
504
+ if (outcome.duplicate) {
505
+ this.diagnostics.onDuplicate?.(parsed.event ?? 'message');
506
+ }
507
+ if (outcome.bufferOverflow) {
508
+ // Hydration buffer overflowed — drop-and-refetch.
509
+ this.schedulePoll();
510
+ }
511
+ if (outcome.gap) {
512
+ this.reportGap(outcome.gap, outcome.stateSuspended);
513
+ // A gap is a loss, not a reorder — polling is the only repair.
514
+ this.schedulePoll();
515
+ }
516
+ this.deliver(outcome.deliveries, outcome.state);
517
+ if (outcome.terminated) {
518
+ this.stopWith({ reason: 'terminal-event' });
519
+ }
520
+ return true;
521
+ }
522
+ /**
523
+ * Surface a detected gap. `getState()` is frozen at its pre-gap value from
524
+ * here until a snapshot repairs it, while events keep flowing to listeners.
525
+ */
526
+ reportGap(gap, stateSuspended) {
527
+ this.diagnostics.onGap?.(gap);
528
+ if (stateSuspended) {
529
+ this.diagnostics.onStateSuspended?.(gap);
530
+ }
531
+ }
532
+ /**
533
+ * Record a server `retry:` reconnection hint, clamped into policy bounds.
534
+ *
535
+ * The value is remote input: `retry: 0` would spin a zero-delay reconnect
536
+ * loop and a huge one would park reconnection for hours.
537
+ */
538
+ applyRetryHint(retryMs) {
539
+ if (!Number.isFinite(retryMs))
540
+ return;
541
+ const { minMs, maxMs } = this.policy.serverRetryHintBounds;
542
+ this.serverRetryHintMs = Math.min(Math.max(retryMs, minMs), maxMs);
543
+ }
544
+ onStaleConnection() {
545
+ // No bytes at all within the window: the connection is silently dead
546
+ // (the original incident class). Force-close; the stream loop reconnects
547
+ // and polls immediately.
548
+ this.currentStreamAbort?.abort();
549
+ }
550
+ armStaleConnection() {
551
+ if (this.policy.staleConnectionTimeoutMs === 'off')
552
+ return;
553
+ this.staleConnection.arm(this.policy.staleConnectionTimeoutMs);
554
+ }
555
+ // --------------------------------------------------------------------
556
+ // Polling
557
+ // --------------------------------------------------------------------
558
+ schedulePoll() {
559
+ if (this.stopped || this.reconciler.isTerminated)
560
+ return;
561
+ if (this.pollInFlight) {
562
+ this.pollQueued = true;
563
+ return;
564
+ }
565
+ void this.executePoll();
566
+ }
567
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: poll execution coordinates gating, status transitions, failure backoff, and coalescing in one place
568
+ async executePoll() {
569
+ const maxPolls = this.policy.subscriptionBudget?.maxPolls;
570
+ if (maxPolls !== undefined && this.pollsAttempted >= maxPolls) {
571
+ this.stopWith({ reason: 'budget-exhausted', limit: 'maxPolls' });
572
+ return;
573
+ }
574
+ this.pollsAttempted += 1;
575
+ this.pollInFlight = true;
576
+ // Polling is the correctness backbone, so a poll that never settles is
577
+ // the worst failure this machine has: it would hold the in-flight latch
578
+ // and leave the deadman unarmed, silently disabling every future poll.
579
+ // Bound it, and let the timeout land in the failure path below.
580
+ const pollAbort = new AbortController();
581
+ const onMasterAbort = () => pollAbort.abort();
582
+ this.abortController.signal.addEventListener('abort', onMasterAbort, { once: true });
583
+ const pollTimeout = new ResettableTimer(() => pollAbort.abort());
584
+ if (this.policy.pollTimeoutMs !== 'off') {
585
+ pollTimeout.arm(this.policy.pollTimeoutMs);
586
+ }
587
+ // A shared gate caps and staggers reconciliation polls across every
588
+ // subscription in the tab, so one server blip does not turn into a
589
+ // simultaneous burst of one poll per subscription.
590
+ let releaseGate;
591
+ try {
592
+ if (this.pollGate) {
593
+ releaseGate = await this.pollGate.acquire({ signal: pollAbort.signal });
594
+ if (this.stopped || this.reconciler.isTerminated)
595
+ return;
596
+ }
597
+ const response = await this.transport.fetchSnapshot(this.binding.buildSnapshotRequest(this.params), { signal: pollAbort.signal });
598
+ if (this.stopped || this.reconciler.isTerminated)
599
+ return;
600
+ if (response.status < 200 || response.status >= 300) {
601
+ this.diagnostics.onPollError?.(new Error(`Snapshot poll failed with status ${response.status}`));
602
+ if (this.policy.unretryableStatuses.includes(response.status)) {
603
+ const recovered = await this.tryAuthChallenge(response.status, 'poll');
604
+ if (this.stopped || this.reconciler.isTerminated)
605
+ return;
606
+ if (!recovered) {
607
+ this.stopWith({
608
+ reason: 'unretryable-status',
609
+ status: response.status,
610
+ channel: 'poll',
611
+ });
612
+ return;
613
+ }
614
+ // Credentials were refreshed — run the refused poll once more. The
615
+ // finally block below releases the latch and starts it.
616
+ this.pollQueued = true;
617
+ return;
618
+ }
619
+ this.onPollFailed();
620
+ return;
621
+ }
622
+ this.pollFailures = 0;
623
+ this.authRetrySpent = false;
624
+ const outcome = this.reconciler.handleSnapshot(response.body);
625
+ if (outcome.stale) {
626
+ this.diagnostics.onStaleSnapshot?.();
627
+ }
628
+ if (outcome.stateRepaired) {
629
+ this.diagnostics.onStateRepaired?.();
630
+ }
631
+ if (outcome.gap) {
632
+ // Flushing the hydration buffer ran the buffered events through the
633
+ // same version gate, and it found a hole. Only a snapshot repairs
634
+ // one, so queue the repair poll (the `finally` below starts it once
635
+ // this poll releases the in-flight latch).
636
+ this.reportGap(outcome.gap, outcome.stateSuspended);
637
+ this.schedulePoll();
638
+ }
639
+ this.deliver(outcome.deliveries, outcome.state);
640
+ if (outcome.hydrationCompleted && !this.stopped) {
641
+ // Hydration can complete while 'connecting' (normal startup) or
642
+ // 'polling' (first successful connect after starting degraded).
643
+ this.setStatus(this.statusAfterHydration());
644
+ }
645
+ if (outcome.terminated) {
646
+ this.stopWith({ reason: 'terminal-event' });
647
+ return;
648
+ }
649
+ if (outcome.deliveries.length > 0) {
650
+ this.idlePolls = 0;
651
+ }
652
+ else {
653
+ this.idlePolls += 1;
654
+ }
655
+ this.armDeadman();
656
+ }
657
+ catch (error) {
658
+ if (this.stopped)
659
+ return;
660
+ this.diagnostics.onPollError?.(error);
661
+ this.onPollFailed();
662
+ }
663
+ finally {
664
+ releaseGate?.();
665
+ pollTimeout.clear();
666
+ this.abortController.signal.removeEventListener('abort', onMasterAbort);
667
+ this.pollInFlight = false;
668
+ if (this.pollQueued && !this.stopped && !this.reconciler.isTerminated) {
669
+ this.pollQueued = false;
670
+ void this.executePoll();
671
+ }
672
+ }
673
+ }
674
+ /**
675
+ * Offer an auth refusal to the application once, so an expired token can be
676
+ * refreshed instead of killing the subscription.
677
+ *
678
+ * Returns `true` when the caller refreshed credentials and the refused
679
+ * request should run again. The retry is spent until a request succeeds, so
680
+ * a hook that keeps returning `true` against a genuinely unauthorized caller
681
+ * cannot spin.
682
+ */
683
+ async tryAuthChallenge(status, channel) {
684
+ const onAuthChallenge = this.onAuthChallenge;
685
+ if (!onAuthChallenge)
686
+ return false;
687
+ if (!this.policy.authChallengeStatuses.includes(status))
688
+ return false;
689
+ // Both channels see the same expired token, so a refusal that arrives
690
+ // while a refresh is running is the SAME failure, not a second one:
691
+ // it waits for that refresh and retries with the credentials it produced.
692
+ // Spending the retry here instead would kill the subscription mid-refresh.
693
+ const inFlight = this.authRefresh;
694
+ if (inFlight)
695
+ return await inFlight;
696
+ if (this.authRetrySpent)
697
+ return false;
698
+ this.authRetrySpent = true;
699
+ const refresh = (async () => {
700
+ try {
701
+ return (await onAuthChallenge({ status, channel })) === true;
702
+ }
703
+ catch (error) {
704
+ this.diagnostics.onListenerError?.(error);
705
+ return false;
706
+ }
707
+ })();
708
+ this.authRefresh = refresh;
709
+ try {
710
+ return await refresh;
711
+ }
712
+ finally {
713
+ // Only a refusal AFTER this refresh completed counts as the second
714
+ // failure, which is what `authRetrySpent` now gates.
715
+ if (this.authRefresh === refresh)
716
+ this.authRefresh = undefined;
717
+ }
718
+ }
719
+ /**
720
+ * Common tail for a failed poll: back off, and stop holding back live
721
+ * events once the snapshot endpoint has failed often enough that waiting
722
+ * for it is worse than delivering what the stream already gave us.
723
+ */
724
+ onPollFailed() {
725
+ this.pollFailures += 1;
726
+ this.deadman.arm(backoffDelay(this.policy.pollFailureBackoff, this.pollFailures, this.random));
727
+ if (this.reconciler.isHydrating &&
728
+ this.pollFailures >= this.policy.hydrationAbandonAfterFailures) {
729
+ const outcome = this.reconciler.abandonHydration();
730
+ if (outcome.gap) {
731
+ // No extra poll here: the snapshot endpoint is the thing that just
732
+ // failed, and `this.deadman` was armed with the failure backoff one
733
+ // line above, which is the repair attempt.
734
+ this.reportGap(outcome.gap, outcome.stateSuspended);
735
+ }
736
+ this.deliver(outcome.deliveries, outcome.state);
737
+ this.setStatus(this.statusAfterHydration());
738
+ if (outcome.terminated)
739
+ this.stopWith({ reason: 'terminal-event' });
740
+ }
741
+ }
742
+ /**
743
+ * Status to report once subscribe-first hydration is over, whether the
744
+ * snapshot landed or the polls were given up on.
745
+ *
746
+ * `streamConnected` is set when the response headers arrive, before any
747
+ * byte of the body, so it cannot stand in for "delivery is healthy": a
748
+ * stream parked with headers and nothing behind them has delivered nothing.
749
+ * Only actual bytes earn `'live'`, the same rule the byte-based recovery in
750
+ * `onStreamActivity` uses. A byte-less stream stays `'connecting'` until it
751
+ * produces one, and `onStreamActivity` promotes it then.
752
+ */
753
+ statusAfterHydration() {
754
+ if (this.streamProducedBytes)
755
+ return 'live';
756
+ if (this.degraded)
757
+ return 'polling';
758
+ return this.streamConnected ? 'connecting' : 'reconnecting';
759
+ }
760
+ armDeadman() {
761
+ if (this.stopped || this.reconciler.isTerminated)
762
+ return;
763
+ if (this.degraded) {
764
+ this.deadman.arm(this.policy.degradedPollIntervalMs);
765
+ return;
766
+ }
767
+ const { factor, maxMs } = this.policy.deadmanIdleBackoff;
768
+ const delay = Math.min(this.policy.deadmanDelayMs * factor ** this.idlePolls, maxMs);
769
+ this.deadman.arm(delay);
770
+ }
771
+ // --------------------------------------------------------------------
772
+ // Delivery + listeners
773
+ // --------------------------------------------------------------------
774
+ deliver(deliveries, state) {
775
+ for (const event of deliveries) {
776
+ for (const listener of this.eventListeners)
777
+ this.runListener(listener, event);
778
+ for (const feed of this.iteratorFeeds)
779
+ feed.push(event);
780
+ }
781
+ if (state !== undefined) {
782
+ for (const listener of this.stateListeners)
783
+ this.runListener(listener, state.value);
784
+ }
785
+ }
786
+ /**
787
+ * Call one application listener in isolation.
788
+ *
789
+ * A throwing listener used to take down the whole delivery: the remaining
790
+ * event listeners, every `events()` iterator and the state listeners were
791
+ * skipped, and the exception unwound into the stream or poll loop, where it
792
+ * was recorded as a transport failure. An application bug then degraded the
793
+ * connection it had nothing to do with. Listener faults are reported through
794
+ * `diagnostics.onListenerError` and go no further.
795
+ */
796
+ runListener(listener, value) {
797
+ try {
798
+ listener(value);
799
+ }
800
+ catch (error) {
801
+ try {
802
+ this.diagnostics.onListenerError?.(error);
803
+ }
804
+ catch {
805
+ // A diagnostics hook that throws too is not allowed to escape either.
806
+ }
807
+ }
808
+ }
809
+ setStatus(status, detail) {
810
+ if (this.statusValue === status)
811
+ return;
812
+ this.statusValue = status;
813
+ for (const listener of this.statusListeners) {
814
+ this.runListener(() => listener(status, detail), undefined);
815
+ }
816
+ }
817
+ waitMatching(matches, opts) {
818
+ return new Promise((resolve, reject) => {
819
+ if (this.stopped) {
820
+ reject(new SubscriptionStoppedError(this.stopDetail ?? { reason: 'manual' }));
821
+ return;
822
+ }
823
+ let timer;
824
+ const offStatus = this.onStatusChange((status, detail) => {
825
+ if (status === 'stopped') {
826
+ cleanup();
827
+ reject(new SubscriptionStoppedError(detail ?? { reason: 'manual' }));
828
+ }
829
+ });
830
+ const offEvent = this.onEvent((event) => {
831
+ if (!matches(event))
832
+ return;
833
+ cleanup();
834
+ resolve(event);
835
+ });
836
+ const cleanup = () => {
837
+ offEvent();
838
+ offStatus();
839
+ if (timer !== undefined)
840
+ clearTimeout(timer);
841
+ };
842
+ if (opts?.timeoutMs !== undefined) {
843
+ timer = setTimeout(() => {
844
+ cleanup();
845
+ reject(new Error(`Timed out after ${opts.timeoutMs}ms waiting for event`));
846
+ }, opts.timeoutMs);
847
+ timer.unref?.();
848
+ }
849
+ });
850
+ }
851
+ }
852
+ /**
853
+ * Create a resilient subscription: SSE as the low-latency channel, short
854
+ * polls as the correctness backbone. See the package README for the state
855
+ * machine and reconciliation semantics.
856
+ */
857
+ export function createResilientSubscription(binding, options) {
858
+ const impl = new ResilientSubscriptionImpl(binding, options);
859
+ return {
860
+ events: () => impl.events(),
861
+ onEvent: (listener) => impl.onEvent(listener),
862
+ getState: () => impl.getState(),
863
+ onStateChange: (listener) => impl.onStateChange(listener),
864
+ get status() {
865
+ return impl.status;
866
+ },
867
+ onStatusChange: (listener) => impl.onStatusChange(listener),
868
+ get result() {
869
+ return impl.result;
870
+ },
871
+ onStop: (listener) => impl.onStop(listener),
872
+ nudge: () => impl.nudge(),
873
+ stop: () => impl.stop(),
874
+ waitFor: (event, opts) => impl.waitFor(event, opts),
875
+ waitForTerminal: (opts) => impl.waitForTerminal(opts),
876
+ };
877
+ }
878
+ //# sourceMappingURL=subscription.js.map