@firebase/data-connect 0.5.0 → 0.6.0-20260409172004

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.
Files changed (42) hide show
  1. package/dist/index.cjs.js +1173 -143
  2. package/dist/index.cjs.js.map +1 -1
  3. package/dist/index.esm.js +1172 -144
  4. package/dist/index.esm.js.map +1 -1
  5. package/dist/index.node.cjs.js +1239 -190
  6. package/dist/index.node.cjs.js.map +1 -1
  7. package/dist/internal.d.ts +133 -12
  8. package/dist/node-esm/index.node.esm.js +1238 -191
  9. package/dist/node-esm/index.node.esm.js.map +1 -1
  10. package/dist/node-esm/src/api/Mutation.d.ts +2 -2
  11. package/dist/node-esm/src/api/query.d.ts +1 -1
  12. package/dist/node-esm/src/core/query/QueryManager.d.ts +22 -3
  13. package/dist/node-esm/src/network/index.d.ts +1 -1
  14. package/dist/node-esm/src/network/manager.d.ts +61 -0
  15. package/dist/node-esm/src/network/{fetch.d.ts → rest/fetch.d.ts} +9 -4
  16. package/dist/node-esm/src/network/rest/index.d.ts +18 -0
  17. package/dist/node-esm/src/network/rest/restTransport.d.ts +33 -0
  18. package/dist/node-esm/src/network/stream/streamTransport.d.ts +243 -0
  19. package/dist/node-esm/src/network/stream/websocket.d.ts +90 -0
  20. package/dist/node-esm/src/network/stream/wire.d.ts +138 -0
  21. package/dist/node-esm/src/network/transport.d.ts +179 -0
  22. package/dist/node-esm/src/util/url.d.ts +3 -1
  23. package/dist/private.d.ts +29 -7
  24. package/dist/public.d.ts +3 -1
  25. package/dist/src/api/Mutation.d.ts +2 -2
  26. package/dist/src/api/query.d.ts +1 -1
  27. package/dist/src/core/query/QueryManager.d.ts +22 -3
  28. package/dist/src/network/index.d.ts +1 -1
  29. package/dist/src/network/manager.d.ts +61 -0
  30. package/dist/src/network/{fetch.d.ts → rest/fetch.d.ts} +9 -4
  31. package/dist/src/network/rest/index.d.ts +18 -0
  32. package/dist/src/network/rest/restTransport.d.ts +33 -0
  33. package/dist/src/network/stream/streamTransport.d.ts +243 -0
  34. package/dist/src/network/stream/websocket.d.ts +90 -0
  35. package/dist/src/network/stream/wire.d.ts +138 -0
  36. package/dist/src/network/transport.d.ts +179 -0
  37. package/dist/src/util/url.d.ts +3 -1
  38. package/package.json +1 -1
  39. package/dist/node-esm/src/network/transport/index.d.ts +0 -81
  40. package/dist/node-esm/src/network/transport/rest.d.ts +0 -49
  41. package/dist/src/network/transport/index.d.ts +0 -81
  42. package/dist/src/network/transport/rest.d.ts +0 -49
@@ -132,6 +132,138 @@ const CallerSdkTypeEnum = {
132
132
  TanstackAngularCore: 'TanstackAngularCore', // Tanstack non-generated Angular SDK
133
133
  GeneratedAngular: 'GeneratedAngular' // Generated Angular SDK
134
134
  };
135
+ /**
136
+ * Constructs the value for the X-Goog-Api-Client header
137
+ * @internal
138
+ */
139
+ function getGoogApiClientValue$1(isUsingGen, callerSdkType) {
140
+ let str = 'gl-js/ fire/' + SDK_VERSION;
141
+ if (callerSdkType !== CallerSdkTypeEnum.Base &&
142
+ callerSdkType !== CallerSdkTypeEnum.Generated) {
143
+ str += ' js/' + callerSdkType.toLowerCase();
144
+ }
145
+ else if (isUsingGen || callerSdkType === CallerSdkTypeEnum.Generated) {
146
+ str += ' js/gen';
147
+ }
148
+ return str;
149
+ }
150
+ /**
151
+ * The base class for all DataConnectTransportInterface implementations. Handles common logic such as
152
+ * URL construction, auth token management, and emulator usage. Concrete transport implementations
153
+ * should extend this class and implement the abstract {@link DataConnectTransportInterface} methods.
154
+ * @internal
155
+ */
156
+ class AbstractDataConnectTransport {
157
+ constructor(options, apiKey, appId, authProvider, appCheckProvider, transportOptions, _isUsingGen = false, _callerSdkType = CallerSdkTypeEnum.Base) {
158
+ this.apiKey = apiKey;
159
+ this.appId = appId;
160
+ this.authProvider = authProvider;
161
+ this.appCheckProvider = appCheckProvider;
162
+ this._isUsingGen = _isUsingGen;
163
+ this._callerSdkType = _callerSdkType;
164
+ this._host = '';
165
+ this._location = 'l';
166
+ this._connectorName = '';
167
+ this._secure = true;
168
+ this._project = 'p';
169
+ this._authToken = null;
170
+ this._appCheckToken = null;
171
+ this._lastToken = null;
172
+ this._isUsingEmulator = false;
173
+ if (transportOptions) {
174
+ if (typeof transportOptions.port === 'number') {
175
+ this._port = transportOptions.port;
176
+ }
177
+ if (typeof transportOptions.sslEnabled !== 'undefined') {
178
+ this._secure = transportOptions.sslEnabled;
179
+ }
180
+ this._host = transportOptions.host;
181
+ }
182
+ const { location, projectId: project, connector, service } = options;
183
+ if (location) {
184
+ this._location = location;
185
+ }
186
+ if (project) {
187
+ this._project = project;
188
+ }
189
+ this._serviceName = service;
190
+ if (!connector) {
191
+ throw new DataConnectError(Code.INVALID_ARGUMENT, 'Connector Name required!');
192
+ }
193
+ this._connectorName = connector;
194
+ this._connectorResourcePath = `projects/${this._project}/locations/${this._location}/services/${this._serviceName}/connectors/${this._connectorName}`;
195
+ this.authProvider?.addTokenChangeListener(token => {
196
+ logDebug(`New Token Available: ${token}`);
197
+ this.onAuthTokenChanged(token);
198
+ });
199
+ this.appCheckProvider?.addTokenChangeListener(result => {
200
+ const { token } = result;
201
+ logDebug(`New App Check Token Available: ${token}`);
202
+ this._appCheckToken = token;
203
+ });
204
+ }
205
+ useEmulator(host, port, isSecure) {
206
+ this._host = host;
207
+ this._isUsingEmulator = true;
208
+ if (typeof port === 'number') {
209
+ this._port = port;
210
+ }
211
+ if (typeof isSecure !== 'undefined') {
212
+ this._secure = isSecure;
213
+ }
214
+ }
215
+ async getWithAuth(forceToken = false) {
216
+ let starterPromise = new Promise(resolve => resolve(this._authToken));
217
+ if (this.appCheckProvider) {
218
+ const appCheckToken = await this.appCheckProvider.getToken();
219
+ if (appCheckToken) {
220
+ this._appCheckToken = appCheckToken.token;
221
+ }
222
+ }
223
+ if (this.authProvider) {
224
+ starterPromise = this.authProvider
225
+ .getToken(/*forceToken=*/ forceToken)
226
+ .then(data => {
227
+ if (!data) {
228
+ return null;
229
+ }
230
+ this._authToken = data.accessToken;
231
+ return this._authToken;
232
+ });
233
+ }
234
+ else {
235
+ starterPromise = new Promise(resolve => resolve(''));
236
+ }
237
+ return starterPromise;
238
+ }
239
+ async withRetry(promiseFactory, retry = false) {
240
+ let isNewToken = false;
241
+ return this.getWithAuth(retry)
242
+ .then(res => {
243
+ isNewToken = this._lastToken !== res;
244
+ this._lastToken = res;
245
+ return res;
246
+ })
247
+ .then(promiseFactory)
248
+ .catch(err => {
249
+ // Only retry if the result is unauthorized and the last token isn't the same as the new one.
250
+ if ('code' in err &&
251
+ err.code === Code.UNAUTHORIZED &&
252
+ !retry &&
253
+ isNewToken) {
254
+ logDebug('Retrying due to unauthorized');
255
+ return this.withRetry(promiseFactory, true);
256
+ }
257
+ throw err;
258
+ });
259
+ }
260
+ _setLastToken(lastToken) {
261
+ this._lastToken = lastToken;
262
+ }
263
+ _setCallerSdkType(callerSdkType) {
264
+ this._callerSdkType = callerSdkType;
265
+ }
266
+ }
135
267
 
136
268
  /**
137
269
  * @license
@@ -149,7 +281,13 @@ const CallerSdkTypeEnum = {
149
281
  * See the License for the specific language governing permissions and
150
282
  * limitations under the License.
151
283
  */
284
+ /** The fetch implementation to be used by the {@link RESTTransport}. */
152
285
  let connectFetch = globalThis.fetch;
286
+ /**
287
+ * This function is ONLY used for testing and for ensuring compatability in environments which may
288
+ * be using a poyfill and/or bundlers. It should not be called by users of the Firebase JS SDK.
289
+ * @internal
290
+ */
153
291
  function initializeFetch(fetchImpl) {
154
292
  connectFetch = fetchImpl;
155
293
  }
@@ -196,14 +334,20 @@ async function dcFetch(url, body, { signal }, appId, accessToken, appCheckToken,
196
334
  response = await connectFetch(url, fetchOptions);
197
335
  }
198
336
  catch (err) {
199
- throw new DataConnectError(Code.OTHER, 'Failed to fetch: ' + JSON.stringify(err));
337
+ const message = err && typeof err === 'object' && 'message' in err
338
+ ? err['message']
339
+ : String(err);
340
+ throw new DataConnectError(Code.OTHER, 'Failed to fetch: ' + message);
200
341
  }
201
342
  let jsonResponse;
202
343
  try {
203
344
  jsonResponse = await response.json();
204
345
  }
205
346
  catch (e) {
206
- throw new DataConnectError(Code.OTHER, JSON.stringify(e));
347
+ const message = e && typeof e === 'object' && 'message' in e
348
+ ? e['message']
349
+ : String(e);
350
+ throw new DataConnectError(Code.OTHER, 'Failed to parse JSON response: ' + message);
207
351
  }
208
352
  const message = getErrorMessage(jsonResponse);
209
353
  if (response.status >= 400) {
@@ -211,32 +355,918 @@ async function dcFetch(url, body, { signal }, appId, accessToken, appCheckToken,
211
355
  if (response.status === 401) {
212
356
  throw new DataConnectError(Code.UNAUTHORIZED, message);
213
357
  }
214
- throw new DataConnectError(Code.OTHER, message);
358
+ throw new DataConnectError(Code.OTHER, message);
359
+ }
360
+ if (jsonResponse.errors && jsonResponse.errors.length) {
361
+ const stringified = JSON.stringify(jsonResponse.errors);
362
+ const failureResponse = {
363
+ errors: jsonResponse.errors,
364
+ data: jsonResponse.data
365
+ };
366
+ throw new DataConnectOperationError('DataConnect error while performing request: ' + stringified, failureResponse);
367
+ }
368
+ if (!jsonResponse.extensions) {
369
+ jsonResponse.extensions = {
370
+ dataConnect: []
371
+ };
372
+ }
373
+ return jsonResponse;
374
+ }
375
+ function getErrorMessage(obj) {
376
+ if ('message' in obj && obj.message) {
377
+ return obj.message;
378
+ }
379
+ return JSON.stringify(obj);
380
+ }
381
+
382
+ /**
383
+ * @license
384
+ * Copyright 2024 Google LLC
385
+ *
386
+ * Licensed under the Apache License, Version 2.0 (the "License");
387
+ * you may not use this file except in compliance with the License.
388
+ * You may obtain a copy of the License at
389
+ *
390
+ * http://www.apache.org/licenses/LICENSE-2.0
391
+ *
392
+ * Unless required by applicable law or agreed to in writing, software
393
+ * distributed under the License is distributed on an "AS IS" BASIS,
394
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
395
+ * See the License for the specific language governing permissions and
396
+ * limitations under the License.
397
+ */
398
+ const PROD_HOST = 'firebasedataconnect.googleapis.com';
399
+ const WEBSOCKET_PATH = 'ws/google.firebase.dataconnect.v1.ConnectorStreamService';
400
+ function restUrlBuilder(projectConfig, transportOptions) {
401
+ const { connector, location, projectId: project, service } = projectConfig;
402
+ const { host, sslEnabled, port } = transportOptions;
403
+ const protocol = sslEnabled ? 'https' : 'http';
404
+ const realHost = host || PROD_HOST;
405
+ let baseUrl = `${protocol}://${realHost}`;
406
+ if (typeof port === 'number') {
407
+ baseUrl += `:${port}`;
408
+ }
409
+ else if (typeof port !== 'undefined') {
410
+ logError('Port type is of an invalid type');
411
+ throw new DataConnectError(Code.INVALID_ARGUMENT, 'Incorrect type for port passed in!');
412
+ }
413
+ return `${baseUrl}/v1/projects/${project}/locations/${location}/services/${service}/connectors/${connector}`;
414
+ }
415
+ function websocketUrlBuilder(projectConfig, transportOptions) {
416
+ const { location } = projectConfig;
417
+ const { host, sslEnabled, port } = transportOptions;
418
+ const protocol = sslEnabled ? 'wss' : 'ws';
419
+ const realHost = host || PROD_HOST;
420
+ let baseUrl = `${protocol}://${realHost}`;
421
+ if (typeof port === 'number') {
422
+ baseUrl += `:${port}`;
423
+ }
424
+ else if (typeof port !== 'undefined') {
425
+ logError('Port type is of an invalid type');
426
+ throw new DataConnectError(Code.INVALID_ARGUMENT, 'Incorrect type for port passed in!');
427
+ }
428
+ return `${baseUrl}/${WEBSOCKET_PATH}/Connect/locations/${location}`;
429
+ }
430
+ function addToken(url, apiKey) {
431
+ if (!apiKey) {
432
+ return url;
433
+ }
434
+ const newUrl = new URL(url);
435
+ newUrl.searchParams.append('key', apiKey);
436
+ return newUrl.toString();
437
+ }
438
+
439
+ /**
440
+ * @license
441
+ * Copyright 2024 Google LLC
442
+ *
443
+ * Licensed under the Apache License, Version 2.0 (the "License");
444
+ * you may not use this file except in compliance with the License.
445
+ * You may obtain a copy of the License at
446
+ *
447
+ * http://www.apache.org/licenses/LICENSE-2.0
448
+ *
449
+ * Unless required by applicable law or agreed to in writing, software
450
+ * distributed under the License is distributed on an "AS IS" BASIS,
451
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
452
+ * See the License for the specific language governing permissions and
453
+ * limitations under the License.
454
+ */
455
+ /**
456
+ * Fetch-based REST implementation of {@link AbstractDataConnectTransport}.
457
+ * @internal
458
+ */
459
+ class RESTTransport extends AbstractDataConnectTransport {
460
+ constructor(options, apiKey, appId, authProvider, appCheckProvider, transportOptions, _isUsingGen = false, _callerSdkType = CallerSdkTypeEnum.Base) {
461
+ super(options, apiKey, appId, authProvider, appCheckProvider, transportOptions, _isUsingGen, _callerSdkType);
462
+ this.invokeQuery = (queryName, body) => {
463
+ const abortController = new AbortController();
464
+ // TODO(mtewani): Update to proper value
465
+ const withAuth = this.withRetry(() => dcFetch(addToken(`${this.endpointUrl}:executeQuery`, this.apiKey), {
466
+ name: this._connectorResourcePath,
467
+ operationName: queryName,
468
+ variables: body
469
+ }, abortController, this.appId, this._authToken, this._appCheckToken, this._isUsingGen, this._callerSdkType, this._isUsingEmulator));
470
+ return withAuth;
471
+ };
472
+ this.invokeMutation = (mutationName, body) => {
473
+ const abortController = new AbortController();
474
+ const taskResult = this.withRetry(() => {
475
+ return dcFetch(addToken(`${this.endpointUrl}:executeMutation`, this.apiKey), {
476
+ name: this._connectorResourcePath,
477
+ operationName: mutationName,
478
+ variables: body
479
+ }, abortController, this.appId, this._authToken, this._appCheckToken, this._isUsingGen, this._callerSdkType, this._isUsingEmulator);
480
+ });
481
+ return taskResult;
482
+ };
483
+ }
484
+ get endpointUrl() {
485
+ return restUrlBuilder({
486
+ connector: this._connectorName,
487
+ location: this._location,
488
+ projectId: this._project,
489
+ service: this._serviceName
490
+ }, {
491
+ host: this._host,
492
+ sslEnabled: this._secure,
493
+ port: this._port
494
+ });
495
+ }
496
+ invokeSubscribe(observer, queryName, body) {
497
+ throw new DataConnectError(Code.NOT_SUPPORTED, 'Subscriptions are not supported using REST!');
498
+ }
499
+ invokeUnsubscribe(queryName, body) {
500
+ throw new DataConnectError(Code.NOT_SUPPORTED, 'Unsubscriptions are not supported using REST!');
501
+ }
502
+ onAuthTokenChanged(newToken) {
503
+ this._authToken = newToken;
504
+ }
505
+ }
506
+
507
+ /**
508
+ * @license
509
+ * Copyright 2026 Google LLC
510
+ *
511
+ * Licensed under the Apache License, Version 2.0 (the "License");
512
+ * you may not use this file except in compliance with the License.
513
+ * You may obtain a copy of the License at
514
+ *
515
+ * http://www.apache.org/licenses/LICENSE-2.0
516
+ *
517
+ * Unless required by applicable law or agreed to in writing, software
518
+ * distributed under the License is distributed on an "AS IS" BASIS,
519
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
520
+ * See the License for the specific language governing permissions and
521
+ * limitations under the License.
522
+ */
523
+ /** The request id of the first request over the stream */
524
+ const FIRST_REQUEST_ID = 1;
525
+ /** Time to wait before closing an idle connection (no active subscriptions) */
526
+ const IDLE_CONNECTION_TIMEOUT_MS = 60 * 1000; // 1 minute
527
+ /**
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.
530
+ * @internal
531
+ */
532
+ class AbstractDataConnectStreamTransport extends AbstractDataConnectTransport {
533
+ constructor() {
534
+ super(...arguments);
535
+ this.pendingClose = false;
536
+ /** True if the transport is unable to connect to the server */
537
+ this.isUnableToConnect = false;
538
+ /** The request ID of the next message to be sent. Monotonically increasing sequence number. */
539
+ this.requestNumber = FIRST_REQUEST_ID;
540
+ /**
541
+ * Map of query/variables to their active execute/resume request bodies.
542
+ */
543
+ this.activeQueryExecuteRequests = new Map();
544
+ /**
545
+ * Map of mutation/variables to their active execute request bodies.
546
+ */
547
+ this.activeMutationExecuteRequests = new Map();
548
+ /**
549
+ * Map of query/variables to their active subscribe request bodies.
550
+ */
551
+ this.activeSubscribeRequests = new Map();
552
+ /**
553
+ * Map of active execution RequestIds and their corresponding Promises and resolvers.
554
+ */
555
+ this.executeRequestPromises = new Map();
556
+ /**
557
+ * Map of active subscription RequestIds and their corresponding observers.
558
+ */
559
+ 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;
564
+ /** Flag to ensure we wait for the initial auth state once per connection attempt. */
565
+ this.hasWaitedForInitialAuth = false;
566
+ /**
567
+ * Tracks if the next message to be sent is the first message of the stream.
568
+ */
569
+ this.isFirstStreamMessage = true;
570
+ /**
571
+ * Tracks the last auth token sent to the server.
572
+ * Used to detect if the token has changed and needs to be resent.
573
+ */
574
+ this.lastSentAuthToken = null;
575
+ }
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;
583
+ }
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);
588
+ }
589
+ /**
590
+ * Generates and returns the next request ID.
591
+ */
592
+ nextRequestId() {
593
+ return (this.requestNumber++).toString();
594
+ }
595
+ /**
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.
602
+ */
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;
620
+ }
621
+ /**
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.
625
+ *
626
+ * @remarks
627
+ * This method returns a promise, but is synchronous.
628
+ */
629
+ trackMutationExecuteRequest(requestId, mapKey, executeBody) {
630
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
631
+ let resolveFn;
632
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
633
+ let rejectFn;
634
+ const responsePromise = new Promise((resolve, reject) => {
635
+ resolveFn = resolve;
636
+ rejectFn = reject;
637
+ });
638
+ const executeRequestPromise = {
639
+ responsePromise,
640
+ resolveFn: resolveFn,
641
+ rejectFn: rejectFn
642
+ };
643
+ const activeRequests = this.activeMutationExecuteRequests.get(mapKey) || [];
644
+ activeRequests.push(executeBody);
645
+ this.activeMutationExecuteRequests.set(mapKey, activeRequests);
646
+ this.executeRequestPromises.set(requestId, executeRequestPromise);
647
+ return executeRequestPromise;
648
+ }
649
+ /**
650
+ * Tracks a subscribe request, storing the request body and the notification observer.
651
+ * @remarks
652
+ * This method is synchronous.
653
+ */
654
+ trackSubscribeRequest(requestId, mapKey, subscribeBody, observer) {
655
+ this.activeSubscribeRequests.set(mapKey, subscribeBody);
656
+ this.subscribeObservers.set(requestId, observer);
657
+ }
658
+ /**
659
+ * Cleans up the query execute request tracking data structures, deleting the tracked request and
660
+ * it's associated promise.
661
+ */
662
+ cleanupQueryExecuteRequest(requestId, mapKey) {
663
+ this.activeQueryExecuteRequests.delete(mapKey);
664
+ this.executeRequestPromises.delete(requestId);
665
+ }
666
+ /**
667
+ * Cleans up the mutation execute request tracking data structures, deleting the tracked request and
668
+ * it's associated promise.
669
+ */
670
+ cleanupMutationExecuteRequest(requestId, mapKey) {
671
+ const executeRequests = this.activeMutationExecuteRequests.get(mapKey);
672
+ if (executeRequests) {
673
+ const updatedRequests = executeRequests.filter(req => req.requestId !== requestId);
674
+ if (updatedRequests.length > 0) {
675
+ this.activeMutationExecuteRequests.set(mapKey, updatedRequests);
676
+ }
677
+ else {
678
+ this.activeMutationExecuteRequests.delete(mapKey);
679
+ }
680
+ }
681
+ this.executeRequestPromises.delete(requestId);
682
+ }
683
+ /**
684
+ * Cleans up the subscribe request tracking data structures, deleting the tracked request and
685
+ * it's associated promise.
686
+ */
687
+ cleanupSubscribeRequest(requestId, mapKey) {
688
+ this.activeSubscribeRequests.delete(mapKey);
689
+ this.subscribeObservers.delete(requestId);
690
+ }
691
+ /**
692
+ * Indicates whether we should include the auth token in the next message.
693
+ * Only true if there is an auth token and it is different from the last sent auth token, or this
694
+ * is the first message.
695
+ */
696
+ get shouldIncludeAuth() {
697
+ return (this.isFirstStreamMessage ||
698
+ (!!this._authToken && this._authToken !== this.lastSentAuthToken));
699
+ }
700
+ /**
701
+ * Called by the concrete transport implementation when the physical connection is ready.
702
+ */
703
+ onConnectionReady() {
704
+ this.isFirstStreamMessage = true;
705
+ this.lastSentAuthToken = null;
706
+ this.hasWaitedForInitialAuth = false;
707
+ }
708
+ /**
709
+ * Attempt to close the connection. Will only close if there are no active requests preventing it
710
+ * from doing so.
711
+ */
712
+ async attemptClose() {
713
+ if (this.hasActiveSubscriptions || this.hasActiveExecuteRequests) {
714
+ return;
715
+ }
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();
734
+ }, IDLE_CONNECTION_TIMEOUT_MS);
735
+ }
736
+ /**
737
+ * Cancel closing the connection.
738
+ */
739
+ cancelClose() {
740
+ if (this.closeTimeout) {
741
+ clearTimeout(this.closeTimeout);
742
+ }
743
+ this.pendingClose = false;
744
+ this.closeTimeoutFinished = false;
745
+ }
746
+ /**
747
+ * Reject all active execute promises and notify all subscribe observers with the given error.
748
+ * Clear active request tracking maps without cancelling or re-invoking any requests.
749
+ */
750
+ rejectAllActiveRequests(code, reason) {
751
+ this.activeQueryExecuteRequests.clear();
752
+ this.activeMutationExecuteRequests.clear();
753
+ this.activeSubscribeRequests.clear();
754
+ const error = new DataConnectError(code, reason);
755
+ for (const [requestId, { rejectFn }] of this.executeRequestPromises) {
756
+ this.executeRequestPromises.delete(requestId);
757
+ rejectFn(error);
758
+ }
759
+ for (const [requestId, observer] of this.subscribeObservers) {
760
+ this.subscribeObservers.delete(requestId);
761
+ observer.onDisconnect(code, reason);
762
+ }
763
+ }
764
+ /**
765
+ * Called by concrete implementations when the stream is successfully closed, gracefully or otherwise.
766
+ */
767
+ onStreamClose(code, reason) {
768
+ this.rejectAllActiveRequests(Code.OTHER, `Stream disconnected with code ${code}: ${reason}`);
769
+ }
770
+ /**
771
+ * Prepares a stream request message by adding necessary headers and metadata.
772
+ * If this is the first message on the stream, it includes the resource name, auth token, and App Check token.
773
+ * If the auth token has refreshed since the last message, it includes the new auth token.
774
+ *
775
+ * This method is called by the concrete transport implementation before sending a message.
776
+ *
777
+ * @returns the requestBody, with attached headers and initial request fields
778
+ */
779
+ prepareMessage(requestBody) {
780
+ const preparedRequestBody = { ...requestBody };
781
+ const headers = {};
782
+ if (this.appId) {
783
+ headers['x-firebase-gmpid'] = this.appId;
784
+ }
785
+ headers['X-Goog-Api-Client'] = getGoogApiClientValue$1(this._isUsingGen, this._callerSdkType);
786
+ if (this.shouldIncludeAuth && this._authToken) {
787
+ headers['X-Firebase-Auth-Token'] = this._authToken;
788
+ this.lastSentAuthToken = this._authToken;
789
+ }
790
+ if (this.isFirstStreamMessage) {
791
+ if (this._appCheckToken) {
792
+ headers['X-Firebase-App-Check'] = this._appCheckToken;
793
+ }
794
+ preparedRequestBody.name = this._connectorResourcePath;
795
+ }
796
+ preparedRequestBody.headers = headers;
797
+ this.isFirstStreamMessage = false;
798
+ return preparedRequestBody;
799
+ }
800
+ // TODO(stephenarosaj): just make this async
801
+ /**
802
+ * Sends a request message to the server via the concrete implementation.
803
+ * Ensures the connection is ready and prepares the message before sending.
804
+ * @returns A promise that resolves when the request message has been sent.
805
+ */
806
+ async sendRequestMessage(requestBody) {
807
+ if (!this.hasWaitedForInitialAuth && this.authProvider) {
808
+ await this.getWithAuth();
809
+ this.hasWaitedForInitialAuth = true;
810
+ }
811
+ if (this.streamIsReady) {
812
+ const prepared = this.prepareMessage(requestBody);
813
+ return this.sendMessage(prepared);
814
+ }
815
+ return this.ensureConnection().then(() => {
816
+ const prepared = this.prepareMessage(requestBody);
817
+ return this.sendMessage(prepared);
818
+ });
819
+ }
820
+ /**
821
+ * Helper to generate a consistent string key for the tracking maps.
822
+ */
823
+ getMapKey(operationName, variables) {
824
+ const sortedVariables = this.sortObjectKeys(variables);
825
+ return JSON.stringify({ operationName, variables: sortedVariables });
826
+ }
827
+ /**
828
+ * Recursively sorts the keys of an object.
829
+ */
830
+ sortObjectKeys(obj) {
831
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
832
+ return obj;
833
+ }
834
+ const sortedObj = {};
835
+ Object.keys(obj)
836
+ .sort()
837
+ .forEach(key => {
838
+ sortedObj[key] = this.sortObjectKeys(obj[key]);
839
+ });
840
+ return sortedObj;
841
+ }
842
+ /**
843
+ * @inheritdoc
844
+ * @remarks
845
+ * This method synchronously updates the request tracking data structures before sending any message.
846
+ * If any asynchronous functionality is added to this function, it MUST be done in a way that
847
+ * preserves the synchronous update of the tracking data structures before the method returns.
848
+ */
849
+ invokeQuery(queryName, variables) {
850
+ const requestId = this.nextRequestId();
851
+ const activeRequestKey = { operationName: queryName, variables };
852
+ const mapKey = this.getMapKey(queryName, variables);
853
+ const executeBody = {
854
+ requestId,
855
+ execute: activeRequestKey
856
+ };
857
+ let { responsePromise, rejectFn } = this.trackQueryExecuteRequest(requestId, mapKey, executeBody);
858
+ responsePromise = responsePromise.finally(() => {
859
+ this.cleanupQueryExecuteRequest(requestId, mapKey);
860
+ if (!this.hasActiveSubscriptions &&
861
+ !this.hasActiveExecuteRequests &&
862
+ this.closeTimeoutFinished) {
863
+ void this.attemptClose();
864
+ }
865
+ });
866
+ // asynchronous, fire and forget
867
+ this.sendRequestMessage(executeBody).catch(err => {
868
+ rejectFn(err);
869
+ });
870
+ return responsePromise;
871
+ }
872
+ /**
873
+ * @inheritdoc
874
+ * @remarks
875
+ * This method synchronously updates the request tracking data structures before sending any message.
876
+ * If any asynchronous functionality is added to this function, it MUST be done in a way that
877
+ * preserves the synchronous update of the tracking data structures before the method returns.
878
+ */
879
+ invokeMutation(mutationName, variables) {
880
+ const requestId = this.nextRequestId();
881
+ const activeRequestKey = { operationName: mutationName, variables };
882
+ const mapKey = this.getMapKey(mutationName, variables);
883
+ const executeBody = {
884
+ requestId,
885
+ execute: activeRequestKey
886
+ };
887
+ let { responsePromise, rejectFn } = this.trackMutationExecuteRequest(requestId, mapKey, executeBody);
888
+ responsePromise = responsePromise.finally(() => {
889
+ this.cleanupMutationExecuteRequest(requestId, mapKey);
890
+ if (!this.hasActiveSubscriptions &&
891
+ !this.hasActiveExecuteRequests &&
892
+ this.closeTimeoutFinished) {
893
+ void this.attemptClose();
894
+ }
895
+ });
896
+ // asynchronous, fire and forget
897
+ this.sendRequestMessage(executeBody).catch(err => {
898
+ rejectFn(err);
899
+ });
900
+ return responsePromise;
901
+ }
902
+ /**
903
+ * @inheritdoc
904
+ * @remarks
905
+ * This method synchronously updates the request tracking data structures before sending any message
906
+ * or cancelling the closing of the stream. If any asynchronous functionality is added to this function,
907
+ * it MUST be done in a way that preserves the synchronous update of the tracking data structures
908
+ * before the method returns.
909
+ */
910
+ 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
+ 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();
927
+ }
928
+ });
929
+ }
930
+ /**
931
+ * @inheritdoc
932
+ * @remarks
933
+ * This method synchronously updates the request tracking data structures before sending any message.
934
+ * If any asynchronous functionality is added to this function, it MUST be done in a way that
935
+ * preserves the synchronous update of the tracking data structures before the method returns.
936
+ */
937
+ invokeUnsubscribe(queryName, variables) {
938
+ const mapKey = this.getMapKey(queryName, variables);
939
+ const subscribeRequest = this.activeSubscribeRequests.get(mapKey);
940
+ if (!subscribeRequest) {
941
+ return;
942
+ }
943
+ const requestId = subscribeRequest.requestId;
944
+ const cancelBody = {
945
+ requestId,
946
+ cancel: {}
947
+ };
948
+ this.cleanupSubscribeRequest(requestId, mapKey);
949
+ // asynchronous, fire and forget
950
+ this.sendRequestMessage(cancelBody).catch(err => {
951
+ logError(`Stream Transport failed to send unsubscribe message: ${err}`);
952
+ });
953
+ if (!this.hasActiveSubscriptions) {
954
+ this.prepareToCloseGracefully();
955
+ }
956
+ }
957
+ onAuthTokenChanged(newToken) {
958
+ const oldAuthToken = this._authToken;
959
+ this._authToken = newToken;
960
+ const oldAuthUid = this.authUid;
961
+ const newAuthUid = this.authProvider?.getAuth()?.getUid();
962
+ this.authUid = newAuthUid;
963
+ // onAuthTokenChanged gets called by the auth provider once it initializes, so we must make sure
964
+ // we don't prematurely disconnect the stream if this is the initial call.
965
+ const isInitialAuth = oldAuthUid === undefined;
966
+ if (isInitialAuth) {
967
+ return;
968
+ }
969
+ if ((oldAuthToken && newToken === null) || // user logged out
970
+ (!oldAuthUid && newAuthUid) || // user logged in
971
+ (oldAuthUid && newAuthUid !== oldAuthUid) // logged in user changed
972
+ ) {
973
+ this.rejectAllActiveRequests(Code.UNAUTHORIZED, 'Stream disconnected due to auth change.');
974
+ void this.attemptClose();
975
+ }
976
+ }
977
+ /**
978
+ * 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.
981
+ * @param response the response from the server.
982
+ */
983
+ async handleResponse(requestId, response) {
984
+ if (this.executeRequestPromises.has(requestId)) {
985
+ // don't clean up the tracking maps here, they're handled automatically when the execute promise settles
986
+ 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));
994
+ }
995
+ else {
996
+ resolveFn(response);
997
+ }
998
+ }
999
+ else if (this.subscribeObservers.has(requestId)) {
1000
+ const observer = this.subscribeObservers.get(requestId);
1001
+ await observer.onData(response);
1002
+ }
1003
+ else {
1004
+ throw new DataConnectError(Code.OTHER, `Stream response contained unrecognized requestId '${requestId}'`);
1005
+ }
1006
+ }
1007
+ }
1008
+
1009
+ /**
1010
+ * @license
1011
+ * Copyright 2026 Google LLC
1012
+ *
1013
+ * Licensed under the Apache License, Version 2.0 (the "License");
1014
+ * you may not use this file except in compliance with the License.
1015
+ * You may obtain a copy of the License at
1016
+ *
1017
+ * http://www.apache.org/licenses/LICENSE-2.0
1018
+ *
1019
+ * Unless required by applicable law or agreed to in writing, software
1020
+ * distributed under the License is distributed on an "AS IS" BASIS,
1021
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1022
+ * See the License for the specific language governing permissions and
1023
+ * limitations under the License.
1024
+ */
1025
+ /** The WebSocket implementation to be used by the {@link WebSocketTransport}. */
1026
+ let connectWebSocket = globalThis.WebSocket;
1027
+ /**
1028
+ * This function is ONLY used for testing and for ensuring compatability in environments which may
1029
+ * be using a poyfill and/or bundlers. It should not be called by users of the Firebase JS SDK.
1030
+ * @internal
1031
+ */
1032
+ function initializeWebSocket(webSocketImpl) {
1033
+ connectWebSocket = webSocketImpl;
1034
+ }
1035
+ /**
1036
+ * The code used to close the WebSocket connection.
1037
+ * This is a protocol-level code, and is not the same as the {@link Code | DataConnect error code}.
1038
+ * @internal
1039
+ */
1040
+ const WEBSOCKET_CLOSE_CODE = 1000;
1041
+ /**
1042
+ * An {@link AbstractDataConnectStreamTransport | Stream Transport} implementation that uses {@link WebSocket | WebSockets} to stream requests and responses.
1043
+ * This class handles the lifecycle of the WebSocket connection, including automatic
1044
+ * reconnection and request correlation.
1045
+ * @internal
1046
+ */
1047
+ class WebSocketTransport extends AbstractDataConnectStreamTransport {
1048
+ get endpointUrl() {
1049
+ return websocketUrlBuilder({
1050
+ connector: this._connectorName,
1051
+ location: this._location,
1052
+ projectId: this._project,
1053
+ service: this._serviceName
1054
+ }, {
1055
+ host: this._host,
1056
+ sslEnabled: this._secure,
1057
+ port: this._port
1058
+ });
1059
+ }
1060
+ /**
1061
+ * Decodes a WebSocket response from a Uint8Array to a JSON object.
1062
+ * Emulator does not send messages as Uint8Arrays, but prod does.
1063
+ */
1064
+ decodeBinaryResponse(data) {
1065
+ if (!this.decoder) {
1066
+ this.decoder = new TextDecoder('utf-8');
1067
+ }
1068
+ return this.decoder.decode(data);
1069
+ }
1070
+ get streamIsReady() {
1071
+ return this.connection?.readyState === WebSocket.OPEN;
1072
+ }
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
+ ensureConnection() {
1092
+ try {
1093
+ if (this.streamIsReady) {
1094
+ return Promise.resolve();
1095
+ }
1096
+ if (this.connectionAttempt) {
1097
+ return this.connectionAttempt;
1098
+ }
1099
+ this.connectionAttempt = new Promise((resolve, reject) => {
1100
+ if (!connectWebSocket) {
1101
+ throw new DataConnectError(Code.OTHER, 'No WebSocket Implementation detected!');
1102
+ }
1103
+ const ws = new connectWebSocket(this.endpointUrl);
1104
+ this.connection = ws;
1105
+ this.connection.binaryType = 'arraybuffer';
1106
+ ws.onopen = () => {
1107
+ this.isUnableToConnect = false;
1108
+ this.onConnectionReady();
1109
+ resolve();
1110
+ };
1111
+ ws.onerror = event => {
1112
+ this.connectionAttempt = null;
1113
+ this.isUnableToConnect = true;
1114
+ const error = new DataConnectError(Code.OTHER, `Error using WebSocket connection, closing WebSocket`);
1115
+ this.handleError(error);
1116
+ reject(error);
1117
+ };
1118
+ ws.onmessage = ev => this.handleWebSocketMessage(ev).catch(async (reason) => {
1119
+ this.handleError(reason);
1120
+ });
1121
+ ws.onclose = ev => this.handleWebsocketDisconnect(ev);
1122
+ });
1123
+ return this.connectionAttempt;
1124
+ }
1125
+ catch (error) {
1126
+ this.handleError(error);
1127
+ throw error;
1128
+ }
1129
+ }
1130
+ openConnection() {
1131
+ return this.ensureConnection().catch(err => {
1132
+ throw new DataConnectError(Code.OTHER, `Failed to open connection: ${err}`);
1133
+ });
1134
+ }
1135
+ closeConnection(code, reason) {
1136
+ if (!this.connection) {
1137
+ this.connectionAttempt = null;
1138
+ return Promise.resolve();
1139
+ }
1140
+ let error;
1141
+ try {
1142
+ if (reason) {
1143
+ // reason string can be max 123 bytes (not characters, bytes)
1144
+ // https://developer.mozilla.org/en-US/docs/Web/API/WebSocketStream/close#parameters
1145
+ const MAX_BYTES = 123;
1146
+ const encoder = new TextEncoder();
1147
+ const bytes = encoder.encode(reason);
1148
+ if (bytes.length <= MAX_BYTES) {
1149
+ this.connection.close(code, reason);
1150
+ }
1151
+ else {
1152
+ const buf = new Uint8Array(MAX_BYTES);
1153
+ const { read } = encoder.encodeInto(reason, buf);
1154
+ const truncatedReason = reason.substring(0, read);
1155
+ this.connection.close(code, truncatedReason);
1156
+ }
1157
+ }
1158
+ else {
1159
+ this.connection.close(code);
1160
+ }
1161
+ }
1162
+ catch (e) {
1163
+ error = e;
1164
+ }
1165
+ finally {
1166
+ this.connection = undefined;
1167
+ this.connectionAttempt = null;
1168
+ }
1169
+ if (error) {
1170
+ return Promise.reject(error);
1171
+ }
1172
+ return Promise.resolve();
215
1173
  }
216
- if (jsonResponse.errors && jsonResponse.errors.length) {
217
- const stringified = JSON.stringify(jsonResponse.errors);
218
- const failureResponse = {
219
- errors: jsonResponse.errors,
220
- data: jsonResponse.data
221
- };
222
- throw new DataConnectOperationError('DataConnect error while performing request: ' + stringified, failureResponse);
1174
+ /**
1175
+ * Handle a disconnection from the server. Initiates graceful clean up and reconnection attempts.
1176
+ * @param ev the {@link CloseEvent} that closed the WebSocket.
1177
+ */
1178
+ handleWebsocketDisconnect(ev) {
1179
+ this.connection = undefined;
1180
+ this.connectionAttempt = null;
1181
+ this.onStreamClose(ev.code, ev.reason);
223
1182
  }
224
- if (!jsonResponse.extensions) {
225
- jsonResponse.extensions = {
226
- dataConnect: []
1183
+ /**
1184
+ * Handle an error that occurred on the WebSocket. Close the connection and reject all active requests.
1185
+ */
1186
+ handleError(error) {
1187
+ logError(`DataConnect WebSocket error, closing stream: ${error}`);
1188
+ let reason = error ? String(error) : 'Unknown Error';
1189
+ if (error instanceof DataConnectError) {
1190
+ reason = error.message;
1191
+ }
1192
+ void this.closeConnection(WEBSOCKET_CLOSE_CODE, reason);
1193
+ }
1194
+ sendMessage(requestBody) {
1195
+ return this.ensureConnection().then(() => {
1196
+ try {
1197
+ this.connection.send(JSON.stringify(requestBody));
1198
+ return Promise.resolve();
1199
+ }
1200
+ catch (err) {
1201
+ this.handleError(err);
1202
+ throw new DataConnectError(Code.OTHER, `Failed to send message: ${String(err)}`);
1203
+ }
1204
+ });
1205
+ }
1206
+ /**
1207
+ * Handles incoming WebSocket messages.
1208
+ * @param ev The {@link MessageEvent} from the WebSocket.
1209
+ */
1210
+ async handleWebSocketMessage(ev) {
1211
+ const result = this.parseWebSocketData(ev.data);
1212
+ const requestId = result.requestId;
1213
+ const response = {
1214
+ data: result.data,
1215
+ errors: result.errors,
1216
+ extensions: result.extensions || { dataConnect: [] }
227
1217
  };
1218
+ await this.handleResponse(requestId, response);
228
1219
  }
229
- return jsonResponse;
230
- }
231
- function getErrorMessage(obj) {
232
- if ('message' in obj && obj.message) {
233
- return obj.message;
1220
+ /**
1221
+ * Parse a response from the server. Assert that it has a {@link DataConnectStreamResponse.requestId | requestId}.
1222
+ * @param data the message from the server to be parsed
1223
+ * @returns the parsed message as a {@link DataConnectStreamResponse}
1224
+ * @throws {DataConnectError} if parsing fails or message is malformed.
1225
+ */
1226
+ parseWebSocketData(
1227
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1228
+ data) {
1229
+ const dataIsString = typeof data === 'string';
1230
+ /** raw websocket message */
1231
+ let webSocketMessage;
1232
+ /** object containing data, errors, and extensions */
1233
+ let result;
1234
+ try {
1235
+ if (dataIsString) {
1236
+ webSocketMessage = JSON.parse(data);
1237
+ }
1238
+ else {
1239
+ webSocketMessage = JSON.parse(this.decodeBinaryResponse(data));
1240
+ }
1241
+ }
1242
+ catch (err) {
1243
+ throw new DataConnectError(Code.OTHER, `Could not parse WebSocket message: ${err instanceof Error ? err.message : String(err)}`);
1244
+ }
1245
+ if (typeof webSocketMessage !== 'object' || webSocketMessage === null) {
1246
+ throw new DataConnectError(Code.OTHER, 'WebSocket message is not an object');
1247
+ }
1248
+ if (dataIsString) {
1249
+ if (!('result' in webSocketMessage)) {
1250
+ throw new DataConnectError(Code.OTHER, 'WebSocket message from emulator did not include result');
1251
+ }
1252
+ if (typeof webSocketMessage.result !== 'object' ||
1253
+ webSocketMessage.result === null) {
1254
+ throw new DataConnectError(Code.OTHER, 'WebSocket message result is not an object');
1255
+ }
1256
+ result = webSocketMessage.result;
1257
+ }
1258
+ else {
1259
+ result = webSocketMessage;
1260
+ }
1261
+ if (!('requestId' in result)) {
1262
+ throw new DataConnectError(Code.OTHER, 'WebSocket message did not include requestId');
1263
+ }
1264
+ return result;
234
1265
  }
235
- return JSON.stringify(obj);
236
1266
  }
237
1267
 
238
1268
  const name = "@firebase/data-connect";
239
- const version = "0.5.0";
1269
+ const version = "0.6.0-20260409172004";
240
1270
 
241
1271
  /**
242
1272
  * @license
@@ -1043,6 +2073,10 @@ class QueryManager {
1043
2073
  this.dc = dc;
1044
2074
  this.cache = cache;
1045
2075
  this.callbacks = new Map();
2076
+ /**
2077
+ * Map of serialized query keys to most recent Query Result. Used as a simple fallback cache
2078
+ * for subsciptions if caching is not enabled.
2079
+ */
1046
2080
  this.subscriptionCache = new Map();
1047
2081
  this.queue = [];
1048
2082
  }
@@ -1088,7 +2122,12 @@ class QueryManager {
1088
2122
  const unsubscribe = () => {
1089
2123
  if (this.callbacks.has(key)) {
1090
2124
  const callbackList = this.callbacks.get(key);
1091
- this.callbacks.set(key, callbackList.filter(callback => callback !== subscription));
2125
+ const newList = callbackList.filter(callback => callback !== subscription);
2126
+ this.callbacks.set(key, newList);
2127
+ if (newList.length === 0) {
2128
+ this.callbacks.delete(key);
2129
+ this.transport.invokeUnsubscribe(queryRef.name, queryRef.variables);
2130
+ }
1092
2131
  onCompleteCallback?.();
1093
2132
  }
1094
2133
  };
@@ -1103,12 +2142,18 @@ class QueryManager {
1103
2142
  const promise = this.preferCacheResults(queryRef, /*allowStale=*/ true);
1104
2143
  // We want to ignore the error and let subscriptions handle it
1105
2144
  promise.then(undefined, err => { });
1106
- if (!this.callbacks.has(key)) {
1107
- this.callbacks.set(key, []);
2145
+ if (this.callbacks.has(key)) {
2146
+ this.callbacks
2147
+ .get(key)
2148
+ .push(subscription);
2149
+ }
2150
+ else {
2151
+ this.callbacks.set(key, [
2152
+ subscription
2153
+ ]);
2154
+ // only invoke subscription if we don't already have an active subscription
2155
+ this.transport.invokeSubscribe(this.makeSubscribeObserver(queryRef), queryRef.name, queryRef.variables);
1108
2156
  }
1109
- this.callbacks
1110
- .get(key)
1111
- .push(subscription);
1112
2157
  return unsubscribe;
1113
2158
  }
1114
2159
  async fetchServerResults(queryRef) {
@@ -1131,8 +2176,7 @@ class QueryManager {
1131
2176
  extensions: getDataConnectExtensionsWithoutMaxAge(originalExtensions),
1132
2177
  toJSON: getRefSerializer(queryRef, result.data, SOURCE_SERVER, fetchTime)
1133
2178
  };
1134
- let updatedKeys = [];
1135
- updatedKeys = await this.updateCache(queryResult, originalExtensions?.dataConnect);
2179
+ const updatedKeys = await this.updateCache(queryResult, originalExtensions?.dataConnect);
1136
2180
  this.publishDataToSubscribers(key, queryResult);
1137
2181
  if (this.cache) {
1138
2182
  await this.publishCacheResultsToSubscribers(updatedKeys, fetchTime);
@@ -1233,6 +2277,7 @@ class QueryManager {
1233
2277
  result.toJSON = getRefSerializer(result.ref, result.data, SOURCE_CACHE, result.fetchTime);
1234
2278
  return result;
1235
2279
  }
2280
+ /** Call the registered onNext callbacks for the given key */
1236
2281
  publishDataToSubscribers(key, queryResult) {
1237
2282
  if (!this.callbacks.has(key)) {
1238
2283
  return;
@@ -1273,6 +2318,80 @@ class QueryManager {
1273
2318
  enableEmulator(host, port) {
1274
2319
  this.transport.useEmulator(host, port);
1275
2320
  }
2321
+ /**
2322
+ * Create a new {@link SubscribeObserver} for the given QueryRef. This will be passed to
2323
+ * {@link DataConnectTransportInterface.invokeSubscribe | invokeSubscribe()} to notify the query
2324
+ * layer of data update notifications or if the stream disconnected.
2325
+ */
2326
+ makeSubscribeObserver(queryRef) {
2327
+ const key = encoderImpl({
2328
+ name: queryRef.name,
2329
+ variables: queryRef.variables,
2330
+ refType: QUERY_STR
2331
+ });
2332
+ return {
2333
+ onData: async (response) => {
2334
+ await this.handleStreamNotification(key, response, queryRef);
2335
+ },
2336
+ onDisconnect: (code, reason) => {
2337
+ this.handleStreamDisconnect(key, code, reason);
2338
+ },
2339
+ onError: error => {
2340
+ this.publishErrorToSubscribers(key, error);
2341
+ }
2342
+ };
2343
+ }
2344
+ /**
2345
+ * Handle a data update notification from the stream. Notify subscribers of results/errors, and
2346
+ * update the cache.
2347
+ */
2348
+ async handleStreamNotification(key, response, queryRef) {
2349
+ if (response.errors && response.errors.length > 0) {
2350
+ const stringified = JSON.stringify(response.errors.map(e => {
2351
+ if (e && typeof e === 'object') {
2352
+ return {
2353
+ message: e.message,
2354
+ code: e.code
2355
+ };
2356
+ }
2357
+ return e;
2358
+ }));
2359
+ const failureResponse = {
2360
+ errors: response.errors,
2361
+ data: response.data
2362
+ };
2363
+ const error = new DataConnectOperationError('DataConnect error received from subscribe notification: ' +
2364
+ stringified, failureResponse);
2365
+ this.publishErrorToSubscribers(key, error);
2366
+ return;
2367
+ }
2368
+ const fetchTime = Date.now().toString();
2369
+ const queryResult = {
2370
+ ref: queryRef,
2371
+ source: SOURCE_SERVER,
2372
+ fetchTime,
2373
+ data: response.data,
2374
+ extensions: getDataConnectExtensionsWithoutMaxAge(response.extensions),
2375
+ toJSON: getRefSerializer(queryRef, response.data, SOURCE_SERVER, fetchTime)
2376
+ };
2377
+ const updatedKeys = await this.updateCache(queryResult, response.extensions?.dataConnect);
2378
+ this.publishDataToSubscribers(key, queryResult);
2379
+ if (this.cache) {
2380
+ await this.publishCacheResultsToSubscribers(updatedKeys, fetchTime);
2381
+ }
2382
+ }
2383
+ /**
2384
+ * Handle a disconnect from the stream. Unsubscribe all callbacks for the given key.
2385
+ */
2386
+ handleStreamDisconnect(key, code, reason) {
2387
+ const error = new DataConnectError(code, reason);
2388
+ this.publishErrorToSubscribers(key, error);
2389
+ const callbacks = this.callbacks.get(key);
2390
+ if (callbacks) {
2391
+ [...callbacks].forEach(cb => cb.unsubscribe());
2392
+ }
2393
+ return;
2394
+ }
1276
2395
  }
1277
2396
  function getMaxAgeFromExtensions(extensions) {
1278
2397
  if (!extensions) {
@@ -1296,7 +2415,7 @@ function getDataConnectExtensionsWithoutMaxAge(extensions) {
1296
2415
 
1297
2416
  /**
1298
2417
  * @license
1299
- * Copyright 2024 Google LLC
2418
+ * Copyright 2026 Google LLC
1300
2419
  *
1301
2420
  * Licensed under the Apache License, Version 2.0 (the "License");
1302
2421
  * you may not use this file except in compliance with the License.
@@ -1310,188 +2429,110 @@ function getDataConnectExtensionsWithoutMaxAge(extensions) {
1310
2429
  * See the License for the specific language governing permissions and
1311
2430
  * limitations under the License.
1312
2431
  */
1313
- const PROD_HOST = 'firebasedataconnect.googleapis.com';
1314
- function urlBuilder(projectConfig, transportOptions) {
1315
- const { connector, location, projectId: project, service } = projectConfig;
1316
- const { host, sslEnabled, port } = transportOptions;
1317
- const protocol = sslEnabled ? 'https' : 'http';
1318
- const realHost = host || PROD_HOST;
1319
- let baseUrl = `${protocol}://${realHost}`;
1320
- if (typeof port === 'number') {
1321
- baseUrl += `:${port}`;
1322
- }
1323
- else if (typeof port !== 'undefined') {
1324
- logError('Port type is of an invalid type');
1325
- throw new DataConnectError(Code.INVALID_ARGUMENT, 'Incorrect type for port passed in!');
1326
- }
1327
- return `${baseUrl}/v1/projects/${project}/locations/${location}/services/${service}/connectors/${connector}`;
1328
- }
1329
- function addToken(url, apiKey) {
1330
- if (!apiKey) {
1331
- return url;
1332
- }
1333
- const newUrl = new URL(url);
1334
- newUrl.searchParams.append('key', apiKey);
1335
- return newUrl.toString();
1336
- }
1337
-
1338
2432
  /**
1339
- * @license
1340
- * Copyright 2024 Google LLC
1341
- *
1342
- * Licensed under the Apache License, Version 2.0 (the "License");
1343
- * you may not use this file except in compliance with the License.
1344
- * You may obtain a copy of the License at
1345
- *
1346
- * http://www.apache.org/licenses/LICENSE-2.0
1347
- *
1348
- * Unless required by applicable law or agreed to in writing, software
1349
- * distributed under the License is distributed on an "AS IS" BASIS,
1350
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1351
- * See the License for the specific language governing permissions and
1352
- * limitations under the License.
2433
+ * Entry point for the transport layer. Manages routing between transport implementations.
2434
+ * @internal
1353
2435
  */
1354
- class RESTTransport {
1355
- constructor(options, apiKey, appId, authProvider, appCheckProvider, transportOptions, _isUsingGen = false, _callerSdkType = CallerSdkTypeEnum.Base) {
2436
+ class DataConnectTransportManager {
2437
+ constructor(options, apiKey, appId, authProvider, appCheckProvider, transportOptions, _isUsingGen = false, _callerSdkType) {
2438
+ this.options = options;
1356
2439
  this.apiKey = apiKey;
1357
2440
  this.appId = appId;
1358
2441
  this.authProvider = authProvider;
1359
2442
  this.appCheckProvider = appCheckProvider;
2443
+ this.transportOptions = transportOptions;
1360
2444
  this._isUsingGen = _isUsingGen;
1361
2445
  this._callerSdkType = _callerSdkType;
1362
- this._host = '';
1363
- this._location = 'l';
1364
- this._connectorName = '';
1365
- this._secure = true;
1366
- this._project = 'p';
1367
- this._accessToken = null;
1368
- this._appCheckToken = null;
1369
- this._lastToken = null;
1370
- this._isUsingEmulator = false;
1371
- // TODO(mtewani): Update U to include shape of body defined in line 13.
1372
- this.invokeQuery = (queryName, body) => {
1373
- const abortController = new AbortController();
1374
- // TODO(mtewani): Update to proper value
1375
- const withAuth = this.withRetry(() => dcFetch(addToken(`${this.endpointUrl}:executeQuery`, this.apiKey), {
1376
- name: `projects/${this._project}/locations/${this._location}/services/${this._serviceName}/connectors/${this._connectorName}`,
1377
- operationName: queryName,
1378
- variables: body
1379
- }, abortController, this.appId, this._accessToken, this._appCheckToken, this._isUsingGen, this._callerSdkType, this._isUsingEmulator));
1380
- return withAuth;
1381
- };
1382
- this.invokeMutation = (mutationName, body) => {
1383
- const abortController = new AbortController();
1384
- const taskResult = this.withRetry(() => {
1385
- return dcFetch(addToken(`${this.endpointUrl}:executeMutation`, this.apiKey), {
1386
- name: `projects/${this._project}/locations/${this._location}/services/${this._serviceName}/connectors/${this._connectorName}`,
1387
- operationName: mutationName,
1388
- variables: body
1389
- }, abortController, this.appId, this._accessToken, this._appCheckToken, this._isUsingGen, this._callerSdkType, this._isUsingEmulator);
1390
- });
1391
- return taskResult;
1392
- };
1393
- if (transportOptions) {
1394
- if (typeof transportOptions.port === 'number') {
1395
- this._port = transportOptions.port;
1396
- }
1397
- if (typeof transportOptions.sslEnabled !== 'undefined') {
1398
- this._secure = transportOptions.sslEnabled;
2446
+ this.isUsingEmulator = false;
2447
+ this.restTransport = new RESTTransport(options, apiKey, appId, authProvider, appCheckProvider, transportOptions, _isUsingGen, _callerSdkType);
2448
+ }
2449
+ /**
2450
+ * Initializes the stream transport if it hasn't been already.
2451
+ */
2452
+ initStreamTransport() {
2453
+ if (!this.streamTransport) {
2454
+ this.streamTransport = new WebSocketTransport(this.options, this.apiKey, this.appId, this.authProvider, this.appCheckProvider, this.transportOptions, this._isUsingGen, this._callerSdkType);
2455
+ if (this.isUsingEmulator && this.transportOptions) {
2456
+ this.streamTransport.useEmulator(this.transportOptions.host, this.transportOptions.port, this.transportOptions.sslEnabled);
1399
2457
  }
1400
- this._host = transportOptions.host;
1401
- }
1402
- const { location, projectId: project, connector, service } = options;
1403
- if (location) {
1404
- this._location = location;
1405
- }
1406
- if (project) {
1407
- this._project = project;
1408
- }
1409
- this._serviceName = service;
1410
- if (!connector) {
1411
- throw new DataConnectError(Code.INVALID_ARGUMENT, 'Connector Name required!');
2458
+ this.streamTransport.onGracefulStreamClose = () => {
2459
+ this.streamTransport = undefined;
2460
+ };
1412
2461
  }
1413
- this._connectorName = connector;
1414
- this.authProvider?.addTokenChangeListener(token => {
1415
- logDebug(`New Token Available: ${token}`);
1416
- this._accessToken = token;
1417
- });
1418
- this.appCheckProvider?.addTokenChangeListener(result => {
1419
- const { token } = result;
1420
- logDebug(`New App Check Token Available: ${token}`);
1421
- this._appCheckToken = token;
1422
- });
2462
+ return this.streamTransport;
1423
2463
  }
1424
- get endpointUrl() {
1425
- return urlBuilder({
1426
- connector: this._connectorName,
1427
- location: this._location,
1428
- projectId: this._project,
1429
- service: this._serviceName
1430
- }, { host: this._host, sslEnabled: this._secure, port: this._port });
2464
+ /**
2465
+ * Returns true if the stream is in a healthy, ready connection state and has active subscriptions.
2466
+ */
2467
+ executeShouldUseStream() {
2468
+ return (!!this.streamTransport &&
2469
+ !this.streamTransport.isPendingClose &&
2470
+ this.streamTransport.streamIsReady &&
2471
+ this.streamTransport.hasActiveSubscriptions &&
2472
+ !this.streamTransport.isUnableToConnect);
1431
2473
  }
1432
- useEmulator(host, port, isSecure) {
1433
- this._host = host;
1434
- this._isUsingEmulator = true;
1435
- if (typeof port === 'number') {
1436
- this._port = port;
1437
- }
1438
- if (typeof isSecure !== 'undefined') {
1439
- this._secure = isSecure;
2474
+ /**
2475
+ * Prefer to use Streaming Transport connection when one is available.
2476
+ * @inheritdoc
2477
+ */
2478
+ invokeQuery(queryName, body) {
2479
+ if (this.executeShouldUseStream()) {
2480
+ return this.streamTransport.invokeQuery(queryName, body).catch(err => {
2481
+ if (this.executeShouldUseStream()) {
2482
+ throw err;
2483
+ }
2484
+ return this.restTransport.invokeQuery(queryName, body);
2485
+ });
1440
2486
  }
2487
+ return this.restTransport.invokeQuery(queryName, body);
1441
2488
  }
1442
- onTokenChanged(newToken) {
1443
- this._accessToken = newToken;
1444
- }
1445
- async getWithAuth(forceToken = false) {
1446
- let starterPromise = new Promise(resolve => resolve(this._accessToken));
1447
- if (this.appCheckProvider) {
1448
- const appCheckToken = await this.appCheckProvider.getToken();
1449
- if (appCheckToken) {
1450
- this._appCheckToken = appCheckToken.token;
1451
- }
1452
- }
1453
- if (this.authProvider) {
1454
- starterPromise = this.authProvider
1455
- .getToken(/*forceToken=*/ forceToken)
1456
- .then(data => {
1457
- if (!data) {
1458
- return null;
2489
+ /**
2490
+ * Prefer to use Streaming Transport connection when one is available.
2491
+ * @inheritdoc
2492
+ */
2493
+ invokeMutation(queryName, body) {
2494
+ if (this.executeShouldUseStream()) {
2495
+ return this.streamTransport.invokeMutation(queryName, body).catch(err => {
2496
+ if (this.executeShouldUseStream()) {
2497
+ throw err;
1459
2498
  }
1460
- this._accessToken = data.accessToken;
1461
- return this._accessToken;
2499
+ return this.restTransport.invokeMutation(queryName, body);
1462
2500
  });
1463
2501
  }
1464
- else {
1465
- starterPromise = new Promise(resolve => resolve(''));
2502
+ return this.restTransport.invokeMutation(queryName, body);
2503
+ }
2504
+ invokeSubscribe(observer, queryName, body) {
2505
+ const streamTransport = this.initStreamTransport();
2506
+ if (streamTransport.isUnableToConnect) {
2507
+ throw new DataConnectError(Code.OTHER, 'Unable to connect streaming connection to server. Subscriptions are unavailable.');
1466
2508
  }
1467
- return starterPromise;
2509
+ streamTransport.invokeSubscribe(observer, queryName, body);
1468
2510
  }
1469
- _setLastToken(lastToken) {
1470
- this._lastToken = lastToken;
2511
+ invokeUnsubscribe(queryName, body) {
2512
+ if (this.streamTransport) {
2513
+ this.streamTransport.invokeUnsubscribe(queryName, body);
2514
+ }
1471
2515
  }
1472
- withRetry(promiseFactory, retry = false) {
1473
- let isNewToken = false;
1474
- return this.getWithAuth(retry)
1475
- .then(res => {
1476
- isNewToken = this._lastToken !== res;
1477
- this._lastToken = res;
1478
- return res;
1479
- })
1480
- .then(promiseFactory)
1481
- .catch(err => {
1482
- // Only retry if the result is unauthorized and the last token isn't the same as the new one.
1483
- if ('code' in err &&
1484
- err.code === Code.UNAUTHORIZED &&
1485
- !retry &&
1486
- isNewToken) {
1487
- logDebug('Retrying due to unauthorized');
1488
- return this.withRetry(promiseFactory, true);
1489
- }
1490
- throw err;
1491
- });
2516
+ useEmulator(host, port, sslEnabled) {
2517
+ this.isUsingEmulator = true;
2518
+ this.transportOptions = { host, port, sslEnabled };
2519
+ this.restTransport.useEmulator(host, port, sslEnabled);
2520
+ if (this.streamTransport) {
2521
+ this.streamTransport.useEmulator(host, port, sslEnabled);
2522
+ }
2523
+ }
2524
+ onAuthTokenChanged(token) {
2525
+ this.restTransport.onAuthTokenChanged(token);
2526
+ if (this.streamTransport) {
2527
+ this.streamTransport.onAuthTokenChanged(token);
2528
+ }
1492
2529
  }
1493
2530
  _setCallerSdkType(callerSdkType) {
1494
2531
  this._callerSdkType = callerSdkType;
2532
+ this.restTransport._setCallerSdkType(callerSdkType);
2533
+ if (this.streamTransport) {
2534
+ this.streamTransport._setCallerSdkType(callerSdkType);
2535
+ }
1495
2536
  }
1496
2537
  }
1497
2538
 
@@ -1657,8 +2698,8 @@ class DataConnect {
1657
2698
  return;
1658
2699
  }
1659
2700
  if (this._transportClass === undefined) {
1660
- logDebug('transportClass not provided. Defaulting to RESTTransport.');
1661
- this._transportClass = RESTTransport;
2701
+ logDebug('transportClass not provided. Defaulting to DataConnectTransportManager.');
2702
+ this._transportClass = DataConnectTransportManager;
1662
2703
  }
1663
2704
  this._authTokenProvider = new FirebaseAuthProvider(this.app.name, this.app.options, this._authProvider);
1664
2705
  const connectorConfig = {
@@ -2101,7 +3142,13 @@ function subscribe(queryRefOrSerializedResult, observerOrOnNext, onError, onComp
2101
3142
  * limitations under the License.
2102
3143
  */
2103
3144
  initializeFetch(fetch);
3145
+ if (typeof WebSocket !== 'undefined') {
3146
+ initializeWebSocket(WebSocket);
3147
+ }
3148
+ else {
3149
+ console.warn('WebSocket is not available in this environment. Use a polyfill or upgrade your Node version to one that supports WebSockets.');
3150
+ }
2104
3151
  registerDataConnect('node');
2105
3152
 
2106
- export { CallerSdkTypeEnum, Code, DataConnect, DataConnectError, DataConnectOperationError, MUTATION_STR, MutationManager, QUERY_STR, QueryFetchPolicy, SOURCE_CACHE, SOURCE_SERVER, StorageType, areTransportOptionsEqual, connectDataConnectEmulator, executeMutation, executeQuery, getDataConnect, makeMemoryCacheProvider, mutationRef, parseOptions, queryRef, setLogLevel, subscribe, terminate, toQueryRef, validateArgs, validateArgsWithOptions, validateDCOptions };
3153
+ export { AbstractDataConnectTransport, CallerSdkTypeEnum, Code, DataConnect, DataConnectError, DataConnectOperationError, MUTATION_STR, MutationManager, QUERY_STR, QueryFetchPolicy, SOURCE_CACHE, SOURCE_SERVER, StorageType, areTransportOptionsEqual, connectDataConnectEmulator, executeMutation, executeQuery, getDataConnect, getGoogApiClientValue$1 as getGoogApiClientValue, makeMemoryCacheProvider, mutationRef, parseOptions, queryRef, setLogLevel, subscribe, terminate, toQueryRef, validateArgs, validateArgsWithOptions, validateDCOptions };
2107
3154
  //# sourceMappingURL=index.node.esm.js.map