@firebase/data-connect 0.6.1-20260505164105 → 0.7.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.
@@ -520,49 +520,128 @@ class RESTTransport extends AbstractDataConnectTransport {
520
520
  * See the License for the specific language governing permissions and
521
521
  * limitations under the License.
522
522
  */
523
- /** The request id of the first request over the stream */
523
+ /** The Request ID of the first request over the stream */
524
524
  const FIRST_REQUEST_ID = 1;
525
- /** Time to wait before closing an idle connection (no active subscriptions) */
525
+ /** Time to wait before closing an idle connection (no active subscriptions). */
526
526
  const IDLE_CONNECTION_TIMEOUT_MS = 60 * 1000; // 1 minute
527
+ /** Initial reconnect delay in ms */
528
+ const INITIAL_RECONNECT_DELAY_MS = 1000;
529
+ /** Max reconnect delay in ms */
530
+ const MAX_RECONNECT_DELAY_MS = 30000;
531
+ /** Max random jitter to add to reconnect delay in ms */
532
+ const MAX_RECONNECT_JITTER_MS = 500;
533
+ /** Factor to multiply delay by on failure */
534
+ const RECONNECT_BACKOFF_FACTOR = 1.3;
535
+ /** Max number of reconnection attempts before giving up */
536
+ const MAX_RECONNECT_ATTEMPTS = 10;
527
537
  /**
528
- * The base class for all {@link DataConnectStreamTransport | Stream Transport} implementations.
529
- * Handles management of logical streams (requests), authentication, data routing to query layer, etc.
538
+ * The base class for all Stream Transport implementations.
539
+ * Handles management of logical streams (requests), authentication, data routing to query layer,
540
+ * request optimizations, etc.
530
541
  * @internal
531
542
  */
532
543
  class AbstractDataConnectStreamTransport extends AbstractDataConnectTransport {
533
- constructor() {
534
- super(...arguments);
535
- this.pendingClose = false;
544
+ /** Is the stream currently waiting to close connection? */
545
+ get isPendingClose() {
546
+ return !!this.idleTimeout;
547
+ }
548
+ /** True if there are active subscriptions on the stream */
549
+ get hasActiveSubscriptions() {
550
+ return this.activeInvokeSubscribeRequests.size > 0;
551
+ }
552
+ /** True if there are active execute or mutation requests on the stream */
553
+ get hasActiveExecuteRequests() {
554
+ return (this.activeInvokeQueryRequests.size > 0 ||
555
+ this.activeInvokeMutationRequests.size > 0);
556
+ }
557
+ constructor(options, apiKey, appId, authProvider, appCheckProvider, transportOptions, _isUsingGen = false, _callerSdkType = CallerSdkTypeEnum.Base) {
558
+ super(options, apiKey, appId, authProvider, appCheckProvider, transportOptions, _isUsingGen, _callerSdkType);
559
+ this.apiKey = apiKey;
560
+ this.appId = appId;
561
+ this.authProvider = authProvider;
562
+ this.appCheckProvider = appCheckProvider;
563
+ this._isUsingGen = _isUsingGen;
564
+ this._callerSdkType = _callerSdkType;
536
565
  /** True if the transport is unable to connect to the server */
537
566
  this.isUnableToConnect = false;
538
- /** The request ID of the next message to be sent. Monotonically increasing sequence number. */
567
+ /** The Request ID of the next message to be sent. Monotonically increasing sequence number starting at {@linkcode FIRST_REQUEST_ID}. */
539
568
  this.requestNumber = FIRST_REQUEST_ID;
540
569
  /**
541
- * Map of query/variables to their active execute/resume request bodies.
570
+ * Map of query/variables to their active {@linkcode ExecuteStreamRequest} or {@linkcode ResumeStreamRequest}
571
+ * request bodies. These requests are de-duplicated by query/variables so that there is only one active
572
+ * request for each query/variables combination.
542
573
  */
543
- this.activeQueryExecuteRequests = new Map();
574
+ this.activeInvokeQueryRequests = new Map();
544
575
  /**
545
- * Map of mutation/variables to their active execute request bodies.
576
+ * Map of query/variables to the promises returned to the user, for invokeQuery requests which are
577
+ * queued and waiting for active request to resolve.
546
578
  */
547
- this.activeMutationExecuteRequests = new Map();
579
+ this.queuedInvokeQueryRequests = new Map();
548
580
  /**
549
- * Map of query/variables to their active subscribe request bodies.
581
+ * Map of mutation/variables to their active {@linkcode ExecuteStreamRequest} request bodies. Mutations
582
+ * can have more than one active request at a time as they are not idempotent, and therefore should
583
+ * not be de-duplicated.
550
584
  */
551
- this.activeSubscribeRequests = new Map();
585
+ this.activeInvokeMutationRequests = new Map();
552
586
  /**
553
- * Map of active execution RequestIds and their corresponding Promises and resolvers.
587
+ * Map of query/variables to their active {@linkcode SubscribeStreamRequest} request bodies. There
588
+ * may only be one active request for each query/variables combination.
589
+ */
590
+ this.activeInvokeSubscribeRequests = new Map();
591
+ /**
592
+ * Map of active {@linkcode ExecuteStreamRequest} RequestIds from {@linkcode invokeQuery} and {@linkcode invokeMutation},
593
+ * and their corresponding {@linkcode InvokeOperationPromise}.
554
594
  */
555
595
  this.executeRequestPromises = new Map();
556
596
  /**
557
- * Map of active subscription RequestIds and their corresponding observers.
597
+ * Map of active {@linkcode ResumeStreamRequest} RequestIds from {@linkcode invokeQuery}, and their
598
+ * corresponding {@linkcode InvokeOperationPromise}.
599
+ */
600
+ this.resumeRequestPromises = new Map();
601
+ /**
602
+ * Map of active {@linkcode invokeSubscribe} RequestIds and their corresponding {@linkcode SubscribeObserver}.
558
603
  */
559
604
  this.subscribeObservers = new Map();
560
- /** current close timeout from setTimeout(), if any */
561
- this.closeTimeout = null;
562
- /** has the close timeout finished? */
563
- this.closeTimeoutFinished = false;
605
+ /**
606
+ * Map of subscribe RequestIds to deferred unsubscription requests. Used when a client unsubscribes
607
+ * while a resume request is actively pending.
608
+ */
609
+ this.pendingCancellations = new Map();
610
+ /** current idle timeout, if any */
611
+ this.idleTimeout = null;
564
612
  /** Flag to ensure we wait for the initial auth state once per connection attempt. */
565
613
  this.hasWaitedForInitialAuth = false;
614
+ /** Delay for next reconnection attempt in ms */
615
+ this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
616
+ /** Timer for reconnection */
617
+ this.reconnectTimer = null;
618
+ /** Number of consecutive reconnection attempts */
619
+ this.reconnectAttempts = 0;
620
+ /** Callback to remove online event listener */
621
+ this.removeOnlineEventListener = null;
622
+ /** Callback to remove visibility change event listener */
623
+ this.removeVisibilityChangeEventListener = null;
624
+ /**
625
+ * Short-circuit a reconnection attempt, if one is pending. Triggered when an online event is
626
+ * dispatched.
627
+ */
628
+ this.onOnlineEventListener = () => {
629
+ if (this.reconnectTimer) {
630
+ this.cancelReconnect();
631
+ void this.attemptReconnect();
632
+ }
633
+ };
634
+ /**
635
+ * Short-circuit a reconnection attempt, if one is pending. Triggered when a visibility change
636
+ * event is dispatched.
637
+ */
638
+ this.onVisibilityChangeEventListener = () => {
639
+ const doc = globalThis.document;
640
+ if (doc && doc.visibilityState === 'visible' && this.reconnectTimer) {
641
+ this.cancelReconnect();
642
+ void this.attemptReconnect();
643
+ }
644
+ };
566
645
  /**
567
646
  * Tracks if the next message to be sent is the first message of the stream.
568
647
  */
@@ -572,62 +651,62 @@ class AbstractDataConnectStreamTransport extends AbstractDataConnectTransport {
572
651
  * Used to detect if the token has changed and needs to be resent.
573
652
  */
574
653
  this.lastSentAuthToken = null;
654
+ this.registerBrowserEventListeners();
575
655
  }
576
- /** Is the stream currently waiting to close connection? */
577
- get isPendingClose() {
578
- return this.pendingClose;
579
- }
580
- /** True if there are active subscriptions on the stream */
581
- get hasActiveSubscriptions() {
582
- return this.activeSubscribeRequests.size > 0;
656
+ /**
657
+ * Register event listeners for browser-specific events like online/offline and visibility changes.
658
+ */
659
+ registerBrowserEventListeners() {
660
+ if ('addEventListener' in globalThis) {
661
+ const listener = this.onOnlineEventListener;
662
+ globalThis.addEventListener('online', listener);
663
+ this.removeOnlineEventListener = () => globalThis.removeEventListener('online', listener);
664
+ }
665
+ const doc = globalThis.document;
666
+ if (doc && 'addEventListener' in doc) {
667
+ const listener = this.onVisibilityChangeEventListener;
668
+ doc.addEventListener('visibilitychange', listener);
669
+ this.removeVisibilityChangeEventListener = () => doc.removeEventListener('visibilitychange', listener);
670
+ }
583
671
  }
584
- /** True if there are active execute or mutation requests on the stream */
585
- get hasActiveExecuteRequests() {
586
- return (this.activeQueryExecuteRequests.size > 0 ||
587
- this.activeMutationExecuteRequests.size > 0);
672
+ /**
673
+ * Remove event listeners registered by {@linkcode AbstractDataConnectStreamTransport.registerBrowserEventListeners | registerBrowserEventListeners()}
674
+ * for browser-specific events like online/offline and visibility changes.
675
+ */
676
+ cleanupBrowserEventListeners() {
677
+ this.removeVisibilityChangeEventListener?.();
678
+ this.removeVisibilityChangeEventListener = null;
679
+ this.removeOnlineEventListener?.();
680
+ this.removeOnlineEventListener = null;
588
681
  }
589
682
  /**
590
- * Generates and returns the next request ID.
683
+ * Disposes of the transport instance, cleaning up event listeners and timers,
684
+ * and closing the connection.
591
685
  */
592
- nextRequestId() {
593
- return (this.requestNumber++).toString();
686
+ async cleanupAndTerminate(code, reason) {
687
+ this.cleanupBrowserEventListeners();
688
+ this.cancelReconnect();
689
+ this.cancelClose();
690
+ this.rejectAllRequests(code ?? Code.OTHER, reason ?? 'Stream disposed.');
691
+ await this.closeConnection();
692
+ this.onCloseCallback?.();
594
693
  }
595
694
  /**
596
- * Tracks a query execution request, storing the request body and creating and storing a promise that
597
- * will be resolved when the response is received.
598
- * @returns The reject function and the response promise.
599
- *
600
- * @remarks
601
- * This method returns a promise, but is synchronous.
695
+ * Generates and returns the next Request ID. Starts at {@linkcode FIRST_REQUEST_ID} and increments
696
+ * for each request sent.
602
697
  */
603
- trackQueryExecuteRequest(requestId, mapKey, executeBody) {
604
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
605
- let resolveFn;
606
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
607
- let rejectFn;
608
- const responsePromise = new Promise((resolve, reject) => {
609
- resolveFn = resolve;
610
- rejectFn = reject;
611
- });
612
- const executeRequestPromise = {
613
- responsePromise,
614
- resolveFn: resolveFn,
615
- rejectFn: rejectFn
616
- };
617
- this.activeQueryExecuteRequests.set(mapKey, executeBody);
618
- this.executeRequestPromises.set(requestId, executeRequestPromise);
619
- return executeRequestPromise;
698
+ nextRequestId() {
699
+ return (this.requestNumber++).toString();
620
700
  }
621
701
  /**
622
- * Tracks a mutation execution request, storing the request body and creating and storing a promise
623
- * that will be resolved when the response is received.
624
- * @returns The reject function and the response promise.
702
+ * Tracks an {@linkcode invokeMutation} request, storing the request body and creating and storing a
703
+ * response promise that will be resolved when the response is received.
704
+ * @returns The tracked {@linkcode InvokeOperationPromise}.
625
705
  *
626
706
  * @remarks
627
707
  * This method returns a promise, but is synchronous.
628
708
  */
629
- trackMutationExecuteRequest(requestId, mapKey, executeBody) {
630
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
709
+ trackInvokeMutationRequest(requestId, mapKey, executeBody) {
631
710
  let resolveFn;
632
711
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
633
712
  let rejectFn;
@@ -640,42 +719,43 @@ class AbstractDataConnectStreamTransport extends AbstractDataConnectTransport {
640
719
  resolveFn: resolveFn,
641
720
  rejectFn: rejectFn
642
721
  };
643
- const activeRequests = this.activeMutationExecuteRequests.get(mapKey) || [];
722
+ const activeRequests = this.activeInvokeMutationRequests.get(mapKey) || [];
644
723
  activeRequests.push(executeBody);
645
- this.activeMutationExecuteRequests.set(mapKey, activeRequests);
724
+ this.activeInvokeMutationRequests.set(mapKey, activeRequests);
646
725
  this.executeRequestPromises.set(requestId, executeRequestPromise);
647
726
  return executeRequestPromise;
648
727
  }
649
728
  /**
650
- * Tracks a subscribe request, storing the request body and the notification observer.
729
+ * Tracks an {@linkcode invokeSubscribe} request, storing the request body and the {@linkcode SubscribeObserver}.
651
730
  * @remarks
652
731
  * This method is synchronous.
653
732
  */
654
- trackSubscribeRequest(requestId, mapKey, subscribeBody, observer) {
655
- this.activeSubscribeRequests.set(mapKey, subscribeBody);
733
+ trackInvokeSubscribeRequest(requestId, mapKey, subscribeBody, observer) {
734
+ this.activeInvokeSubscribeRequests.set(mapKey, subscribeBody);
656
735
  this.subscribeObservers.set(requestId, observer);
657
736
  }
658
737
  /**
659
738
  * Cleans up the query execute request tracking data structures, deleting the tracked request and
660
739
  * it's associated promise.
661
740
  */
662
- cleanupQueryExecuteRequest(requestId, mapKey) {
663
- this.activeQueryExecuteRequests.delete(mapKey);
741
+ cleanupInvokeQueryRequest(requestId, mapKey) {
742
+ this.activeInvokeQueryRequests.delete(mapKey);
664
743
  this.executeRequestPromises.delete(requestId);
744
+ this.resumeRequestPromises.delete(requestId);
665
745
  }
666
746
  /**
667
747
  * Cleans up the mutation execute request tracking data structures, deleting the tracked request and
668
748
  * it's associated promise.
669
749
  */
670
- cleanupMutationExecuteRequest(requestId, mapKey) {
671
- const executeRequests = this.activeMutationExecuteRequests.get(mapKey);
750
+ cleanupInvokeMutationRequest(requestId, mapKey) {
751
+ const executeRequests = this.activeInvokeMutationRequests.get(mapKey);
672
752
  if (executeRequests) {
673
- const updatedRequests = executeRequests.filter(req => req.requestId !== requestId);
753
+ const updatedRequests = executeRequests.filter(request => request.requestId !== requestId);
674
754
  if (updatedRequests.length > 0) {
675
- this.activeMutationExecuteRequests.set(mapKey, updatedRequests);
755
+ this.activeInvokeMutationRequests.set(mapKey, updatedRequests);
676
756
  }
677
757
  else {
678
- this.activeMutationExecuteRequests.delete(mapKey);
758
+ this.activeInvokeMutationRequests.delete(mapKey);
679
759
  }
680
760
  }
681
761
  this.executeRequestPromises.delete(requestId);
@@ -684,10 +764,72 @@ class AbstractDataConnectStreamTransport extends AbstractDataConnectTransport {
684
764
  * Cleans up the subscribe request tracking data structures, deleting the tracked request and
685
765
  * it's associated promise.
686
766
  */
687
- cleanupSubscribeRequest(requestId, mapKey) {
688
- this.activeSubscribeRequests.delete(mapKey);
767
+ cleanupInvokeSubscribeRequest(requestId, mapKey) {
768
+ this.activeInvokeSubscribeRequests.delete(mapKey);
689
769
  this.subscribeObservers.delete(requestId);
690
770
  }
771
+ /**
772
+ * Cancel reconnecting.
773
+ */
774
+ cancelReconnect() {
775
+ if (this.reconnectTimer) {
776
+ clearTimeout(this.reconnectTimer);
777
+ this.reconnectTimer = null;
778
+ }
779
+ }
780
+ /**
781
+ * Starts the backoff timer for reconnection attempts. We use an exponential backoff with randomized
782
+ * jitter to prevent overwhelming the backend with connection attempts.
783
+ */
784
+ startReconnectBackoff() {
785
+ if (this.reconnectTimer) {
786
+ return;
787
+ }
788
+ if (this.reconnectAttempts++ >= MAX_RECONNECT_ATTEMPTS) {
789
+ const errorString = 'Stream disconnected and could not reconnect - max stream reconnection attempts reached.';
790
+ logError(errorString);
791
+ void this.cleanupAndTerminate(Code.OTHER, errorString);
792
+ return;
793
+ }
794
+ const delay = this.reconnectDelayMs;
795
+ this.reconnectDelayMs = Math.min(this.reconnectDelayMs * RECONNECT_BACKOFF_FACTOR, MAX_RECONNECT_DELAY_MS);
796
+ const jitter = Math.random() * MAX_RECONNECT_JITTER_MS;
797
+ this.reconnectTimer = setTimeout(() => {
798
+ this.reconnectTimer = null;
799
+ void this.attemptReconnect();
800
+ }, delay + jitter);
801
+ }
802
+ async attemptReconnect() {
803
+ try {
804
+ await this.ensureConnection();
805
+ // reset on success
806
+ this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
807
+ this.reconnectAttempts = 0;
808
+ await this.retriggerActiveRequests();
809
+ }
810
+ catch (e) {
811
+ if (e instanceof FirebaseError) {
812
+ logDebug(`Reconnect attempt #${this.reconnectAttempts} failed with Firebase error: ${e.message}. Retrying...`);
813
+ this.startReconnectBackoff();
814
+ }
815
+ else {
816
+ logError(`Unexpected error during reconnect attempt #${this.reconnectAttempts}: ${e}`);
817
+ void this.cleanupAndTerminate(Code.OTHER, `Unexpected error during reconnect attempt #${this.reconnectAttempts}: ${e}`);
818
+ }
819
+ }
820
+ }
821
+ /**
822
+ * Retriggers all active requests on the stream connection - first subscribes, then query executions,
823
+ * and skip mutations. Used after a successful reconnection.
824
+ */
825
+ async retriggerActiveRequests() {
826
+ for (const [_, subscribeBody] of this.activeInvokeSubscribeRequests) {
827
+ await this.sendRequestMessage(subscribeBody);
828
+ }
829
+ for (const [_, requestBody] of this.activeInvokeQueryRequests) {
830
+ await this.sendRequestMessage(requestBody);
831
+ }
832
+ }
691
833
  /**
692
834
  * Indicates whether we should include the auth token in the next message.
693
835
  * Only true if there is an auth token and it is different from the last sent auth token, or this
@@ -706,66 +848,91 @@ class AbstractDataConnectStreamTransport extends AbstractDataConnectTransport {
706
848
  this.hasWaitedForInitialAuth = false;
707
849
  }
708
850
  /**
709
- * Attempt to close the connection. Will only close if there are no active requests preventing it
710
- * from doing so.
851
+ * Begin closing the connection. Waits for {@linkcode IDLE_CONNECTION_TIMEOUT_MS} without cleaning up
852
+ * any requests (meaning it will not close after the timeout unless the requests are closed first).
853
+ * This is a graceful close - it will be called when there are no more active subscriptions, so
854
+ * there's no need to cleanup.
711
855
  */
712
- async attemptClose() {
713
- if (this.hasActiveSubscriptions || this.hasActiveExecuteRequests) {
856
+ startIdleCloseTimeout() {
857
+ if (this.idleTimeout) {
714
858
  return;
715
859
  }
716
- this.cancelClose();
717
- await this.closeConnection();
718
- this.onGracefulStreamClose?.();
719
- }
720
- /**
721
- * Begin closing the connection. Waits for and cleans up all active requests, and waits for
722
- * {@link IDLE_CONNECTION_TIMEOUT_MS}. This is a graceful close - it will be called when there are
723
- * no more active subscriptions, so there's no need to cleanup.
724
- */
725
- prepareToCloseGracefully() {
726
- if (this.pendingClose) {
727
- return;
728
- }
729
- this.pendingClose = true;
730
- this.closeTimeoutFinished = false;
731
- this.closeTimeout = setTimeout(() => {
732
- this.closeTimeoutFinished = true;
733
- void this.attemptClose();
860
+ this.idleTimeout = setTimeout(() => {
861
+ this.idleTimeout = null;
862
+ // Safety check: Don't close if new requests arrived during the timeout!
863
+ if (this.hasActiveSubscriptions || this.hasActiveExecuteRequests) {
864
+ return;
865
+ }
866
+ void this.cleanupAndTerminate(Code.OTHER, 'Stream closed due to idleness.');
734
867
  }, IDLE_CONNECTION_TIMEOUT_MS);
735
868
  }
736
869
  /**
737
870
  * Cancel closing the connection.
738
871
  */
739
872
  cancelClose() {
740
- if (this.closeTimeout) {
741
- clearTimeout(this.closeTimeout);
873
+ if (this.idleTimeout) {
874
+ clearTimeout(this.idleTimeout);
875
+ this.idleTimeout = null;
742
876
  }
743
- this.pendingClose = false;
744
- this.closeTimeoutFinished = false;
745
877
  }
746
878
  /**
747
879
  * Reject all active execute promises and notify all subscribe observers with the given error.
748
880
  * Clear active request tracking maps without cancelling or re-invoking any requests.
749
881
  */
750
- rejectAllActiveRequests(code, reason) {
751
- this.activeQueryExecuteRequests.clear();
752
- this.activeMutationExecuteRequests.clear();
753
- this.activeSubscribeRequests.clear();
882
+ rejectAllRequests(code, reason) {
883
+ this.activeInvokeQueryRequests.clear();
884
+ this.activeInvokeMutationRequests.clear();
885
+ this.activeInvokeSubscribeRequests.clear();
754
886
  const error = new DataConnectError(code, reason);
887
+ for (const [mapKey, { rejectFn }] of this.queuedInvokeQueryRequests) {
888
+ this.queuedInvokeQueryRequests.delete(mapKey);
889
+ rejectFn(error);
890
+ }
755
891
  for (const [requestId, { rejectFn }] of this.executeRequestPromises) {
756
892
  this.executeRequestPromises.delete(requestId);
757
893
  rejectFn(error);
758
894
  }
895
+ for (const [requestId, { rejectFn }] of this.resumeRequestPromises) {
896
+ this.resumeRequestPromises.delete(requestId);
897
+ rejectFn(error);
898
+ }
759
899
  for (const [requestId, observer] of this.subscribeObservers) {
760
900
  this.subscribeObservers.delete(requestId);
761
901
  observer.onDisconnect(code, reason);
762
902
  }
903
+ this.pendingCancellations.clear();
904
+ this.cancelReconnect();
905
+ }
906
+ /**
907
+ * Reject all mutation execute promises.
908
+ * Clear active request tracking maps without cancelling or re-invoking any requests.
909
+ */
910
+ rejectAllMutationsOnReconnect() {
911
+ const error = new DataConnectError(Code.OTHER, 'Mutation aborted due to stream disconnect.');
912
+ for (const [_, requests] of this.activeInvokeMutationRequests) {
913
+ for (const request of requests) {
914
+ const promise = this.executeRequestPromises.get(request.requestId);
915
+ if (promise) {
916
+ promise.rejectFn(error);
917
+ this.executeRequestPromises.delete(request.requestId);
918
+ }
919
+ }
920
+ }
921
+ this.activeInvokeMutationRequests.clear();
763
922
  }
764
923
  /**
765
924
  * Called by concrete implementations when the stream is successfully closed, gracefully or otherwise.
766
925
  */
767
926
  onStreamClose(code, reason) {
768
- this.rejectAllActiveRequests(Code.OTHER, `Stream disconnected with code ${code}: ${reason}`);
927
+ this.cancelClose();
928
+ if (!this.hasActiveSubscriptions) {
929
+ // skip reconnection if there are no active subscriptions
930
+ void this.cleanupAndTerminate(Code.OTHER, `Stream disconnected while idle with code ${code}: ${reason}`);
931
+ return;
932
+ }
933
+ logDebug(`Stream disconnected with code ${code}: ${reason}. Attempting reconnect...`);
934
+ this.rejectAllMutationsOnReconnect();
935
+ this.startReconnectBackoff();
769
936
  }
770
937
  /**
771
938
  * Prepares a stream request message by adding necessary headers and metadata.
@@ -797,7 +964,6 @@ class AbstractDataConnectStreamTransport extends AbstractDataConnectTransport {
797
964
  this.isFirstStreamMessage = false;
798
965
  return preparedRequestBody;
799
966
  }
800
- // TODO(stephenarosaj): just make this async
801
967
  /**
802
968
  * Sends a request message to the server via the concrete implementation.
803
969
  * Ensures the connection is ready and prepares the message before sending.
@@ -818,7 +984,7 @@ class AbstractDataConnectStreamTransport extends AbstractDataConnectTransport {
818
984
  });
819
985
  }
820
986
  /**
821
- * Helper to generate a consistent string key for the tracking maps.
987
+ * Helper to generate a consistent string key for the request tracking maps.
822
988
  */
823
989
  getMapKey(operationName, variables) {
824
990
  const sortedVariables = this.sortObjectKeys(variables);
@@ -847,28 +1013,114 @@ class AbstractDataConnectStreamTransport extends AbstractDataConnectTransport {
847
1013
  * preserves the synchronous update of the tracking data structures before the method returns.
848
1014
  */
849
1015
  invokeQuery(queryName, variables) {
850
- const requestId = this.nextRequestId();
851
- const activeRequestKey = { operationName: queryName, variables };
852
1016
  const mapKey = this.getMapKey(queryName, variables);
853
- const executeBody = {
854
- requestId,
855
- execute: activeRequestKey
856
- };
857
- let { responsePromise, rejectFn } = this.trackQueryExecuteRequest(requestId, mapKey, executeBody);
1017
+ if (this.activeInvokeQueryRequests.has(mapKey)) {
1018
+ return this.queueInvokeQueryRequest(mapKey);
1019
+ }
1020
+ return this.executeOrResumeQuery(queryName, variables, mapKey);
1021
+ }
1022
+ /**
1023
+ * Queue a new query execute request to be executed after the currently active query execute
1024
+ * request resolves, and track + return a promise associated with the queued request. If there is
1025
+ * already a queued request for this mapKey, return the existing queued request's promise instead.
1026
+ */
1027
+ queueInvokeQueryRequest(mapKey) {
1028
+ const existingQueued = this.queuedInvokeQueryRequests.get(mapKey);
1029
+ if (existingQueued) {
1030
+ // only queue one request per mapKey - return existing queued request promise
1031
+ return existingQueued.responsePromise;
1032
+ }
1033
+ let resolveFn;
1034
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1035
+ let rejectFn;
1036
+ const responsePromise = new Promise((resolve, reject) => {
1037
+ resolveFn = resolve;
1038
+ rejectFn = reject;
1039
+ });
1040
+ this.queuedInvokeQueryRequests.set(mapKey, {
1041
+ responsePromise,
1042
+ resolveFn: resolveFn,
1043
+ rejectFn: rejectFn
1044
+ });
1045
+ return responsePromise;
1046
+ }
1047
+ /**
1048
+ * Executes or resumes a query. Does not check for any active requests which may be overwritten by
1049
+ * this request - this should be handled by the caller.
1050
+ */
1051
+ executeOrResumeQuery(queryName, variables, mapKey, queuedInvokeOperationPromise) {
1052
+ const activeSubscription = this.activeInvokeSubscribeRequests.get(mapKey);
1053
+ let resolveFn;
1054
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1055
+ let rejectFn;
1056
+ let responsePromise;
1057
+ // track the existing queued promise if one exists - otherwise create a new one
1058
+ if (queuedInvokeOperationPromise) {
1059
+ resolveFn = queuedInvokeOperationPromise.resolveFn;
1060
+ rejectFn = queuedInvokeOperationPromise.rejectFn;
1061
+ responsePromise = queuedInvokeOperationPromise.responsePromise;
1062
+ }
1063
+ else {
1064
+ responsePromise = new Promise((resolve, reject) => {
1065
+ resolveFn = resolve;
1066
+ rejectFn = reject;
1067
+ });
1068
+ }
1069
+ let requestId;
1070
+ let requestBody;
1071
+ if (activeSubscription) {
1072
+ // resume!
1073
+ requestId = activeSubscription.requestId;
1074
+ requestBody = { requestId, resume: {} };
1075
+ this.resumeRequestPromises.set(requestId, {
1076
+ responsePromise,
1077
+ resolveFn: resolveFn,
1078
+ rejectFn: rejectFn
1079
+ });
1080
+ }
1081
+ else {
1082
+ // execute!
1083
+ requestId = this.nextRequestId();
1084
+ requestBody = {
1085
+ requestId,
1086
+ execute: { operationName: queryName, variables }
1087
+ };
1088
+ this.executeRequestPromises.set(requestId, {
1089
+ responsePromise,
1090
+ resolveFn: resolveFn,
1091
+ rejectFn: rejectFn
1092
+ });
1093
+ }
1094
+ this.activeInvokeQueryRequests.set(mapKey, requestBody);
858
1095
  responsePromise = responsePromise.finally(() => {
859
- this.cleanupQueryExecuteRequest(requestId, mapKey);
860
- if (!this.hasActiveSubscriptions &&
861
- !this.hasActiveExecuteRequests &&
862
- this.closeTimeoutFinished) {
863
- void this.attemptClose();
864
- }
1096
+ this.onInvokeQueryRequestFulfilled(queryName, variables, mapKey, requestId);
865
1097
  });
866
- // asynchronous, fire and forget
867
- this.sendRequestMessage(executeBody).catch(err => {
1098
+ this.sendRequestMessage(requestBody).catch(err => {
868
1099
  rejectFn(err);
869
1100
  });
870
1101
  return responsePromise;
871
1102
  }
1103
+ /**
1104
+ * When a query invoke request is fulfilled, clean up and trigger the next queued
1105
+ * request if one exists.
1106
+ */
1107
+ onInvokeQueryRequestFulfilled(queryName, variables, mapKey, requestId) {
1108
+ this.cleanupInvokeQueryRequest(requestId, mapKey);
1109
+ const deferredCancel = this.pendingCancellations.get(requestId);
1110
+ if (deferredCancel) {
1111
+ this.pendingCancellations.delete(requestId);
1112
+ this.cancelSubscription(requestId, mapKey);
1113
+ }
1114
+ const queuedRequestPromise = this.queuedInvokeQueryRequests.get(mapKey);
1115
+ if (!queuedRequestPromise) {
1116
+ if (!this.hasActiveSubscriptions && !this.hasActiveExecuteRequests) {
1117
+ this.startIdleCloseTimeout();
1118
+ }
1119
+ return;
1120
+ }
1121
+ this.queuedInvokeQueryRequests.delete(mapKey);
1122
+ void this.executeOrResumeQuery(queryName, variables, mapKey, queuedRequestPromise);
1123
+ }
872
1124
  /**
873
1125
  * @inheritdoc
874
1126
  * @remarks
@@ -884,13 +1136,11 @@ class AbstractDataConnectStreamTransport extends AbstractDataConnectTransport {
884
1136
  requestId,
885
1137
  execute: activeRequestKey
886
1138
  };
887
- let { responsePromise, rejectFn } = this.trackMutationExecuteRequest(requestId, mapKey, executeBody);
1139
+ let { responsePromise, rejectFn } = this.trackInvokeMutationRequest(requestId, mapKey, executeBody);
888
1140
  responsePromise = responsePromise.finally(() => {
889
- this.cleanupMutationExecuteRequest(requestId, mapKey);
890
- if (!this.hasActiveSubscriptions &&
891
- !this.hasActiveExecuteRequests &&
892
- this.closeTimeoutFinished) {
893
- void this.attemptClose();
1141
+ this.cleanupInvokeMutationRequest(requestId, mapKey);
1142
+ if (!this.hasActiveSubscriptions && !this.hasActiveExecuteRequests) {
1143
+ this.startIdleCloseTimeout();
894
1144
  }
895
1145
  });
896
1146
  // asynchronous, fire and forget
@@ -908,24 +1158,35 @@ class AbstractDataConnectStreamTransport extends AbstractDataConnectTransport {
908
1158
  * before the method returns.
909
1159
  */
910
1160
  invokeSubscribe(observer, queryName, variables) {
911
- // if we are waiting to close the stream, cancel closing!
912
- this.cancelClose();
913
- const requestId = this.nextRequestId();
914
- const activeRequestKey = { operationName: queryName, variables };
915
1161
  const mapKey = this.getMapKey(queryName, variables);
916
- const subscribeBody = {
917
- requestId,
918
- subscribe: activeRequestKey
919
- };
920
- this.trackSubscribeRequest(requestId, mapKey, subscribeBody, observer);
921
- // asynchronous, fire and forget
922
- this.sendRequestMessage(subscribeBody).catch(err => {
923
- observer.onError(err instanceof Error ? err : new Error(String(err)));
924
- this.cleanupSubscribeRequest(requestId, mapKey);
925
- if (!this.hasActiveSubscriptions) {
926
- this.prepareToCloseGracefully();
1162
+ const existingSubscribe = this.activeInvokeSubscribeRequests.get(mapKey);
1163
+ // if this query is pending cancellation, cancel the cancellation!
1164
+ if (existingSubscribe) {
1165
+ const requestId = existingSubscribe.requestId;
1166
+ if (this.pendingCancellations.has(requestId)) {
1167
+ this.pendingCancellations.delete(requestId);
1168
+ this.subscribeObservers.set(requestId, observer);
927
1169
  }
928
- });
1170
+ }
1171
+ else {
1172
+ const requestId = this.nextRequestId();
1173
+ const activeRequestKey = { operationName: queryName, variables };
1174
+ const subscribeBody = {
1175
+ requestId,
1176
+ subscribe: activeRequestKey
1177
+ };
1178
+ this.trackInvokeSubscribeRequest(requestId, mapKey, subscribeBody, observer);
1179
+ // asynchronous, fire and forget
1180
+ this.sendRequestMessage(subscribeBody).catch(err => {
1181
+ observer.onError(err instanceof Error ? err : new Error(String(err)));
1182
+ this.cleanupInvokeSubscribeRequest(requestId, mapKey);
1183
+ if (!this.hasActiveSubscriptions) {
1184
+ this.startIdleCloseTimeout();
1185
+ }
1186
+ });
1187
+ }
1188
+ // if we are waiting to close the stream, cancel closing!
1189
+ this.cancelClose();
929
1190
  }
930
1191
  /**
931
1192
  * @inheritdoc
@@ -936,22 +1197,38 @@ class AbstractDataConnectStreamTransport extends AbstractDataConnectTransport {
936
1197
  */
937
1198
  invokeUnsubscribe(queryName, variables) {
938
1199
  const mapKey = this.getMapKey(queryName, variables);
939
- const subscribeRequest = this.activeSubscribeRequests.get(mapKey);
1200
+ const subscribeRequest = this.activeInvokeSubscribeRequests.get(mapKey);
940
1201
  if (!subscribeRequest) {
941
1202
  return;
942
1203
  }
943
1204
  const requestId = subscribeRequest.requestId;
1205
+ this.subscribeObservers.delete(requestId);
1206
+ const resumePromise = this.resumeRequestPromises.get(requestId);
1207
+ if (resumePromise) {
1208
+ this.pendingCancellations.set(requestId, {
1209
+ operationName: queryName,
1210
+ variables
1211
+ });
1212
+ return;
1213
+ }
1214
+ this.cancelSubscription(requestId, mapKey);
1215
+ }
1216
+ /**
1217
+ * Cancels a subscription, cleans up the request tracking data structures, and checks to see if we
1218
+ * should close the stream due to inactivity.
1219
+ */
1220
+ cancelSubscription(requestId, mapKey) {
1221
+ this.cleanupInvokeSubscribeRequest(requestId, mapKey);
944
1222
  const cancelBody = {
945
1223
  requestId,
946
1224
  cancel: {}
947
1225
  };
948
- this.cleanupSubscribeRequest(requestId, mapKey);
949
1226
  // asynchronous, fire and forget
950
1227
  this.sendRequestMessage(cancelBody).catch(err => {
951
1228
  logError(`Stream Transport failed to send unsubscribe message: ${err}`);
952
1229
  });
953
1230
  if (!this.hasActiveSubscriptions) {
954
- this.prepareToCloseGracefully();
1231
+ this.startIdleCloseTimeout();
955
1232
  }
956
1233
  }
957
1234
  onAuthTokenChanged(newToken) {
@@ -970,38 +1247,58 @@ class AbstractDataConnectStreamTransport extends AbstractDataConnectTransport {
970
1247
  (!oldAuthUid && newAuthUid) || // user logged in
971
1248
  (oldAuthUid && newAuthUid !== oldAuthUid) // logged in user changed
972
1249
  ) {
973
- this.rejectAllActiveRequests(Code.UNAUTHORIZED, 'Stream disconnected due to auth change.');
974
- void this.attemptClose();
1250
+ void this.cleanupAndTerminate(Code.UNAUTHORIZED, 'Stream disconnected due to auth change.');
975
1251
  }
976
1252
  }
977
1253
  /**
978
1254
  * Handle a response message from the server. Called by the connection-specific implementation after
979
- * it's transformed a message from the server into a {@link DataConnectResponse}.
980
- * @param requestId the requestId associated with this response.
1255
+ * it's transformed a message from the server into a {@linkcode DataConnectResponse}.
1256
+ * @param requestId the Request ID associated with this response.
981
1257
  * @param response the response from the server.
982
1258
  */
983
1259
  async handleResponse(requestId, response) {
984
1260
  if (this.executeRequestPromises.has(requestId)) {
985
- // don't clean up the tracking maps here, they're handled automatically when the execute promise settles
986
1261
  const { resolveFn, rejectFn } = this.executeRequestPromises.get(requestId);
987
- if (response.errors && response.errors.length) {
988
- const failureResponse = {
989
- errors: response.errors,
990
- data: response.data
991
- };
992
- const stringified = JSON.stringify(response.errors);
993
- rejectFn(new DataConnectOperationError('DataConnect error while performing request: ' + stringified, failureResponse));
1262
+ this.handleInvokeOperationResponse(resolveFn, rejectFn, response);
1263
+ }
1264
+ else if (this.subscribeObservers.has(requestId) ||
1265
+ this.resumeRequestPromises.has(requestId)) {
1266
+ const observer = this.subscribeObservers.get(requestId);
1267
+ const resumePromise = this.resumeRequestPromises.get(requestId);
1268
+ if (resumePromise) {
1269
+ this.resumeRequestPromises.delete(requestId);
1270
+ const { resolveFn, rejectFn } = resumePromise;
1271
+ this.handleInvokeOperationResponse(resolveFn, rejectFn, response);
994
1272
  }
995
- else {
996
- resolveFn(response);
1273
+ if (observer) {
1274
+ try {
1275
+ await observer.onData(response);
1276
+ }
1277
+ catch (e) {
1278
+ logError(`Error in observer callback: ${e}`);
1279
+ }
997
1280
  }
998
1281
  }
999
- else if (this.subscribeObservers.has(requestId)) {
1000
- const observer = this.subscribeObservers.get(requestId);
1001
- await observer.onData(response);
1282
+ else {
1283
+ logError(`Stream response contained unrecognized requestId '${requestId}'`);
1284
+ }
1285
+ }
1286
+ /**
1287
+ * Handles an invoke operation response, resolving or rejecting the promise returned to the user
1288
+ * Does not handle any cleanup for requests - this should be handled by the caller or the promise's
1289
+ * finally() block.
1290
+ */
1291
+ handleInvokeOperationResponse(resolveFn, rejectFn, response) {
1292
+ if (response.errors && response.errors.length) {
1293
+ const failureResponse = {
1294
+ errors: response.errors,
1295
+ data: response.data
1296
+ };
1297
+ const stringified = JSON.stringify(response.errors);
1298
+ rejectFn(new DataConnectOperationError('DataConnect error while performing request: ' + stringified, failureResponse));
1002
1299
  }
1003
1300
  else {
1004
- throw new DataConnectError(Code.OTHER, `Stream response contained unrecognized requestId '${requestId}'`);
1301
+ resolveFn(response);
1005
1302
  }
1006
1303
  }
1007
1304
  }
@@ -1045,6 +1342,18 @@ const WEBSOCKET_CLOSE_CODE = 1000;
1045
1342
  * @internal
1046
1343
  */
1047
1344
  class WebSocketTransport extends AbstractDataConnectStreamTransport {
1345
+ constructor() {
1346
+ super(...arguments);
1347
+ /** Decodes binary WebSocket responses to strings */
1348
+ this.decoder = undefined;
1349
+ /** The current connection to the server. Undefined if disconnected. */
1350
+ this.connection = undefined;
1351
+ /**
1352
+ * Current connection attempt. If null, we are not currently attemping to connect (not connected,
1353
+ * or already connected). Will be resolved or rejected when the connection is opened or fails to open.
1354
+ */
1355
+ this.connectionAttempt = null;
1356
+ }
1048
1357
  get endpointUrl() {
1049
1358
  return websocketUrlBuilder({
1050
1359
  connector: this._connectorName,
@@ -1070,24 +1379,6 @@ class WebSocketTransport extends AbstractDataConnectStreamTransport {
1070
1379
  get streamIsReady() {
1071
1380
  return this.connection?.readyState === WebSocket.OPEN;
1072
1381
  }
1073
- constructor(options, apiKey, appId, authProvider, appCheckProvider, transportOptions, _isUsingGen = false, _callerSdkType = CallerSdkTypeEnum.Base) {
1074
- super(options, apiKey, appId, authProvider, appCheckProvider, transportOptions, _isUsingGen, _callerSdkType);
1075
- this.apiKey = apiKey;
1076
- this.appId = appId;
1077
- this.authProvider = authProvider;
1078
- this.appCheckProvider = appCheckProvider;
1079
- this._isUsingGen = _isUsingGen;
1080
- this._callerSdkType = _callerSdkType;
1081
- /** Decodes binary WebSocket responses to strings */
1082
- this.decoder = undefined;
1083
- /** The current connection to the server. Undefined if disconnected. */
1084
- this.connection = undefined;
1085
- /**
1086
- * Current connection attempt. If null, we are not currently attemping to connect (not connected,
1087
- * or already connected). Will be resolved or rejected when the connection is opened or fails to open.
1088
- */
1089
- this.connectionAttempt = null;
1090
- }
1091
1382
  ensureConnection() {
1092
1383
  try {
1093
1384
  if (this.streamIsReady) {
@@ -1266,7 +1557,7 @@ class WebSocketTransport extends AbstractDataConnectStreamTransport {
1266
1557
  }
1267
1558
 
1268
1559
  const name = "@firebase/data-connect";
1269
- const version = "0.6.1-20260505164105";
1560
+ const version = "0.7.0";
1270
1561
 
1271
1562
  /**
1272
1563
  * @license
@@ -2455,7 +2746,7 @@ class DataConnectTransportManager {
2455
2746
  if (this.isUsingEmulator && this.transportOptions) {
2456
2747
  this.streamTransport.useEmulator(this.transportOptions.host, this.transportOptions.port, this.transportOptions.sslEnabled);
2457
2748
  }
2458
- this.streamTransport.onGracefulStreamClose = () => {
2749
+ this.streamTransport.onCloseCallback = () => {
2459
2750
  this.streamTransport = undefined;
2460
2751
  };
2461
2752
  }