@electric-sql/client 1.1.0 → 1.1.2

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/src/client.ts CHANGED
@@ -441,6 +441,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
441
441
  #activeSnapshotRequests = 0 // counter for concurrent snapshot requests
442
442
  #midStreamPromise?: Promise<void>
443
443
  #midStreamPromiseResolver?: () => void
444
+ #lastSseConnectionStartTime?: number
445
+ #minSseConnectionDuration = 1000 // Minimum expected SSE connection duration (1 second)
446
+ #consecutiveShortSseConnections = 0
447
+ #maxShortSseConnections = 3 // Fall back to long polling after this many short connections
448
+ #sseFallbackToLongPolling = false
449
+ #sseBackoffBaseDelay = 100 // Base delay for exponential backoff (ms)
450
+ #sseBackoffMaxDelay = 5000 // Maximum delay cap (ms)
444
451
 
445
452
  constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
446
453
  this.options = { subscribe: true, ...options }
@@ -507,32 +514,61 @@ export class ShapeStream<T extends Row<unknown> = Row>
507
514
  await this.#requestShape()
508
515
  } catch (err) {
509
516
  this.#error = err
517
+
518
+ // Check if onError handler wants to retry
510
519
  if (this.#onError) {
511
520
  const retryOpts = await this.#onError(err as Error)
512
- if (typeof retryOpts === `object`) {
513
- this.#reset()
514
-
515
- if (`params` in retryOpts) {
516
- this.options.params = retryOpts.params
521
+ // Guard against null (typeof null === "object" in JavaScript)
522
+ if (retryOpts && typeof retryOpts === `object`) {
523
+ // Update params/headers but don't reset offset
524
+ // We want to continue from where we left off, not refetch everything
525
+ if (retryOpts.params) {
526
+ // Merge new params with existing params to preserve other parameters
527
+ this.options.params = {
528
+ ...(this.options.params ?? {}),
529
+ ...retryOpts.params,
530
+ }
517
531
  }
518
532
 
519
- if (`headers` in retryOpts) {
520
- this.options.headers = retryOpts.headers
533
+ if (retryOpts.headers) {
534
+ // Merge new headers with existing headers to preserve other headers
535
+ this.options.headers = {
536
+ ...(this.options.headers ?? {}),
537
+ ...retryOpts.headers,
538
+ }
521
539
  }
522
540
 
523
- // Restart
541
+ // Clear the error since we're retrying
542
+ this.#error = null
543
+
544
+ // Restart from current offset
524
545
  this.#started = false
525
- this.#start()
546
+ await this.#start()
547
+ return
548
+ }
549
+ // onError returned void, meaning it doesn't want to retry
550
+ // This is an unrecoverable error, notify subscribers
551
+ if (err instanceof Error) {
552
+ this.#sendErrorToSubscribers(err)
526
553
  }
554
+ this.#connected = false
555
+ this.#tickPromiseRejecter?.()
527
556
  return
528
557
  }
529
558
 
530
- // If no handler is provided for errors just throw so the error still bubbles up.
531
- throw err
532
- } finally {
559
+ // No onError handler provided, this is an unrecoverable error
560
+ // Notify subscribers and throw
561
+ if (err instanceof Error) {
562
+ this.#sendErrorToSubscribers(err)
563
+ }
533
564
  this.#connected = false
534
565
  this.#tickPromiseRejecter?.()
566
+ throw err
535
567
  }
568
+
569
+ // Normal completion, clean up
570
+ this.#connected = false
571
+ this.#tickPromiseRejecter?.()
536
572
  }
537
573
 
538
574
  async #requestShape(): Promise<void> {
@@ -613,12 +649,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
613
649
  )
614
650
  return this.#requestShape()
615
651
  } else {
616
- // Notify subscribers
617
- this.#sendErrorToSubscribers(e)
618
-
619
652
  // errors that have reached this point are not actionable without
620
653
  // additional user input, such as 400s or failures to read the
621
- // body of a response, so we exit the loop
654
+ // body of a response, so we exit the loop and let #start handle it
655
+ // Note: We don't notify subscribers here because onError might recover
622
656
  throw e
623
657
  }
624
658
  } finally {
@@ -832,7 +866,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
832
866
  this.#isUpToDate &&
833
867
  useSse &&
834
868
  !this.#isRefreshing &&
835
- !opts.resumingFromPause
869
+ !opts.resumingFromPause &&
870
+ !this.#sseFallbackToLongPolling
836
871
  ) {
837
872
  opts.fetchUrl.searchParams.set(EXPERIMENTAL_LIVE_SSE_QUERY_PARAM, `true`)
838
873
  opts.fetchUrl.searchParams.set(LIVE_SSE_QUERY_PARAM, `true`)
@@ -871,6 +906,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
871
906
  }): Promise<void> {
872
907
  const { fetchUrl, requestAbortController, headers } = opts
873
908
  const fetch = this.#sseFetchClient
909
+
910
+ // Track when the SSE connection starts
911
+ this.#lastSseConnectionStartTime = Date.now()
912
+
874
913
  try {
875
914
  let buffer: Array<Message<T>> = []
876
915
  await fetchEventSource(fetchUrl.toString(), {
@@ -916,6 +955,44 @@ export class ShapeStream<T extends Row<unknown> = Row>
916
955
  throw new FetchBackoffAbortError()
917
956
  }
918
957
  throw error
958
+ } finally {
959
+ // Check if the SSE connection closed too quickly
960
+ // This can happen when responses are cached or when the proxy/server
961
+ // is misconfigured for SSE and closes the connection immediately
962
+ const connectionDuration = Date.now() - this.#lastSseConnectionStartTime!
963
+ const wasAborted = requestAbortController.signal.aborted
964
+
965
+ if (connectionDuration < this.#minSseConnectionDuration && !wasAborted) {
966
+ // Connection was too short - likely a cached response or misconfiguration
967
+ this.#consecutiveShortSseConnections++
968
+
969
+ if (
970
+ this.#consecutiveShortSseConnections >= this.#maxShortSseConnections
971
+ ) {
972
+ // Too many short connections - fall back to long polling
973
+ this.#sseFallbackToLongPolling = true
974
+ console.warn(
975
+ `[Electric] SSE connections are closing immediately (possibly due to proxy buffering or misconfiguration). ` +
976
+ `Falling back to long polling. ` +
977
+ `Your proxy must support streaming SSE responses (not buffer the complete response). ` +
978
+ `Configuration: Nginx add 'X-Accel-Buffering: no', Caddy add 'flush_interval -1' to reverse_proxy. ` +
979
+ `Note: Do NOT disable caching entirely - Electric uses cache headers to enable request collapsing for efficiency.`
980
+ )
981
+ } else {
982
+ // Add exponential backoff with full jitter to prevent tight infinite loop
983
+ // Formula: random(0, min(cap, base * 2^attempt))
984
+ const maxDelay = Math.min(
985
+ this.#sseBackoffMaxDelay,
986
+ this.#sseBackoffBaseDelay *
987
+ Math.pow(2, this.#consecutiveShortSseConnections)
988
+ )
989
+ const delayMs = Math.floor(Math.random() * maxDelay)
990
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
991
+ }
992
+ } else if (connectionDuration >= this.#minSseConnectionDuration) {
993
+ // Connection was healthy - reset counter
994
+ this.#consecutiveShortSseConnections = 0
995
+ }
919
996
  }
920
997
  }
921
998
 
@@ -1090,6 +1167,9 @@ export class ShapeStream<T extends Row<unknown> = Row>
1090
1167
  this.#connected = false
1091
1168
  this.#schema = undefined
1092
1169
  this.#activeSnapshotRequests = 0
1170
+ // Reset SSE fallback state to try SSE again after reset
1171
+ this.#consecutiveShortSseConnections = 0
1172
+ this.#sseFallbackToLongPolling = false
1093
1173
  }
1094
1174
 
1095
1175
  /**