@powersync/common 1.31.1 → 1.32.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.
@@ -5,6 +5,7 @@ import { SyncStatus } from '../db/crud/SyncStatus.js';
5
5
  import { UploadQueueStats } from '../db/crud/UploadQueueStatus.js';
6
6
  import { Schema } from '../db/schema/Schema.js';
7
7
  import { BaseObserver } from '../utils/BaseObserver.js';
8
+ import { ConnectionManager } from './ConnectionManager.js';
8
9
  import { SQLOpenFactory, SQLOpenOptions } from './SQLOpenFactory.js';
9
10
  import { PowerSyncBackendConnector } from './connection/PowerSyncBackendConnector.js';
10
11
  import { BucketStorageAdapter } from './sync/bucket/BucketStorageAdapter.js';
@@ -114,13 +115,14 @@ export declare abstract class AbstractPowerSyncDatabase extends BaseObserver<Pow
114
115
  * Current connection status.
115
116
  */
116
117
  currentStatus: SyncStatus;
117
- syncStreamImplementation?: StreamingSyncImplementation;
118
118
  sdkVersion: string;
119
119
  protected bucketStorageAdapter: BucketStorageAdapter;
120
- private syncStatusListenerDisposer?;
121
120
  protected _isReadyPromise: Promise<void>;
121
+ protected connectionManager: ConnectionManager;
122
+ get syncStreamImplementation(): StreamingSyncImplementation | null;
122
123
  protected _schema: Schema;
123
124
  private _database;
125
+ protected runExclusiveMutex: Mutex;
124
126
  constructor(options: PowerSyncDatabaseOptionsWithDBAdapter);
125
127
  constructor(options: PowerSyncDatabaseOptionsWithOpenFactory);
126
128
  constructor(options: PowerSyncDatabaseOptionsWithSettings);
@@ -190,6 +192,11 @@ export declare abstract class AbstractPowerSyncDatabase extends BaseObserver<Pow
190
192
  */
191
193
  init(): Promise<void>;
192
194
  resolvedConnectionOptions(options?: PowerSyncConnectionOptions): RequiredAdditionalConnectionOptions;
195
+ /**
196
+ * Locking mechanism for exclusively running critical portions of connect/disconnect operations.
197
+ * Locking here is mostly only important on web for multiple tab scenarios.
198
+ */
199
+ protected runExclusive<T>(callback: () => Promise<T>): Promise<T>;
193
200
  /**
194
201
  * Connects to stream of events from the PowerSync instance.
195
202
  */
@@ -2,12 +2,14 @@ import { Mutex } from 'async-mutex';
2
2
  import { EventIterator } from 'event-iterator';
3
3
  import Logger from 'js-logger';
4
4
  import { isBatchedUpdateNotification } from '../db/DBAdapter.js';
5
+ import { FULL_SYNC_PRIORITY } from '../db/crud/SyncProgress.js';
5
6
  import { SyncStatus } from '../db/crud/SyncStatus.js';
6
7
  import { UploadQueueStats } from '../db/crud/UploadQueueStatus.js';
7
8
  import { BaseObserver } from '../utils/BaseObserver.js';
8
9
  import { ControlledExecutor } from '../utils/ControlledExecutor.js';
9
- import { mutexRunExclusive } from '../utils/mutex.js';
10
10
  import { throttleTrailing } from '../utils/async.js';
11
+ import { mutexRunExclusive } from '../utils/mutex.js';
12
+ import { ConnectionManager } from './ConnectionManager.js';
11
13
  import { isDBAdapter, isSQLOpenFactory, isSQLOpenOptions } from './SQLOpenFactory.js';
12
14
  import { runOnSchemaChange } from './runOnSchemaChange.js';
13
15
  import { PSInternalTable } from './sync/bucket/BucketStorageAdapter.js';
@@ -15,7 +17,6 @@ import { CrudBatch } from './sync/bucket/CrudBatch.js';
15
17
  import { CrudEntry } from './sync/bucket/CrudEntry.js';
16
18
  import { CrudTransaction } from './sync/bucket/CrudTransaction.js';
17
19
  import { DEFAULT_CRUD_UPLOAD_THROTTLE_MS, DEFAULT_RETRY_DELAY_MS } from './sync/stream/AbstractStreamingSyncImplementation.js';
18
- import { FULL_SYNC_PRIORITY } from '../db/crud/SyncProgress.js';
19
20
  const POWERSYNC_TABLE_MATCH = /(^ps_data__|^ps_data_local__)/;
20
21
  const DEFAULT_DISCONNECT_CLEAR_OPTIONS = {
21
22
  clearLocal: true
@@ -59,13 +60,16 @@ export class AbstractPowerSyncDatabase extends BaseObserver {
59
60
  * Current connection status.
60
61
  */
61
62
  currentStatus;
62
- syncStreamImplementation;
63
63
  sdkVersion;
64
64
  bucketStorageAdapter;
65
- syncStatusListenerDisposer;
66
65
  _isReadyPromise;
66
+ connectionManager;
67
+ get syncStreamImplementation() {
68
+ return this.connectionManager.syncStreamImplementation;
69
+ }
67
70
  _schema;
68
71
  _database;
72
+ runExclusiveMutex;
69
73
  constructor(options) {
70
74
  super();
71
75
  this.options = options;
@@ -92,7 +96,31 @@ export class AbstractPowerSyncDatabase extends BaseObserver {
92
96
  this._schema = schema;
93
97
  this.ready = false;
94
98
  this.sdkVersion = '';
99
+ this.runExclusiveMutex = new Mutex();
95
100
  // Start async init
101
+ this.connectionManager = new ConnectionManager({
102
+ createSyncImplementation: async (connector, options) => {
103
+ await this.waitForReady();
104
+ return this.runExclusive(async () => {
105
+ const sync = this.generateSyncStreamImplementation(connector, this.resolvedConnectionOptions(options));
106
+ const onDispose = sync.registerListener({
107
+ statusChanged: (status) => {
108
+ this.currentStatus = new SyncStatus({
109
+ ...status.toJSON(),
110
+ hasSynced: this.currentStatus?.hasSynced || !!status.lastSyncedAt
111
+ });
112
+ this.iterateListeners((cb) => cb.statusChanged?.(this.currentStatus));
113
+ }
114
+ });
115
+ await sync.waitForReady();
116
+ return {
117
+ sync,
118
+ onDispose
119
+ };
120
+ });
121
+ },
122
+ logger: this.logger
123
+ });
96
124
  this._isReadyPromise = this.initialize();
97
125
  }
98
126
  /**
@@ -265,30 +293,18 @@ export class AbstractPowerSyncDatabase extends BaseObserver {
265
293
  crudUploadThrottleMs: options?.crudUploadThrottleMs ?? this.options.crudUploadThrottleMs ?? DEFAULT_CRUD_UPLOAD_THROTTLE_MS
266
294
  };
267
295
  }
296
+ /**
297
+ * Locking mechanism for exclusively running critical portions of connect/disconnect operations.
298
+ * Locking here is mostly only important on web for multiple tab scenarios.
299
+ */
300
+ runExclusive(callback) {
301
+ return this.runExclusiveMutex.runExclusive(callback);
302
+ }
268
303
  /**
269
304
  * Connects to stream of events from the PowerSync instance.
270
305
  */
271
306
  async connect(connector, options) {
272
- await this.waitForReady();
273
- // close connection if one is open
274
- await this.disconnect();
275
- if (this.closed) {
276
- throw new Error('Cannot connect using a closed client');
277
- }
278
- const resolvedConnectOptions = this.resolvedConnectionOptions(options);
279
- this.syncStreamImplementation = this.generateSyncStreamImplementation(connector, resolvedConnectOptions);
280
- this.syncStatusListenerDisposer = this.syncStreamImplementation.registerListener({
281
- statusChanged: (status) => {
282
- this.currentStatus = new SyncStatus({
283
- ...status.toJSON(),
284
- hasSynced: this.currentStatus?.hasSynced || !!status.lastSyncedAt
285
- });
286
- this.iterateListeners((cb) => cb.statusChanged?.(this.currentStatus));
287
- }
288
- });
289
- await this.syncStreamImplementation.waitForReady();
290
- this.syncStreamImplementation.triggerCrudUpload();
291
- await this.syncStreamImplementation.connect(options);
307
+ return this.connectionManager.connect(connector, options);
292
308
  }
293
309
  /**
294
310
  * Close the sync connection.
@@ -296,11 +312,7 @@ export class AbstractPowerSyncDatabase extends BaseObserver {
296
312
  * Use {@link connect} to connect again.
297
313
  */
298
314
  async disconnect() {
299
- await this.waitForReady();
300
- await this.syncStreamImplementation?.disconnect();
301
- this.syncStatusListenerDisposer?.();
302
- await this.syncStreamImplementation?.dispose();
303
- this.syncStreamImplementation = undefined;
315
+ return this.connectionManager.disconnect();
304
316
  }
305
317
  /**
306
318
  * Disconnect and clear the database.
@@ -339,7 +351,7 @@ export class AbstractPowerSyncDatabase extends BaseObserver {
339
351
  if (disconnect) {
340
352
  await this.disconnect();
341
353
  }
342
- await this.syncStreamImplementation?.dispose();
354
+ await this.connectionManager.close();
343
355
  await this.database.close();
344
356
  this.closed = true;
345
357
  }
@@ -0,0 +1,80 @@
1
+ import { ILogger } from 'js-logger';
2
+ import { BaseListener, BaseObserver } from '../utils/BaseObserver.js';
3
+ import { PowerSyncBackendConnector } from './connection/PowerSyncBackendConnector.js';
4
+ import { PowerSyncConnectionOptions, StreamingSyncImplementation } from './sync/stream/AbstractStreamingSyncImplementation.js';
5
+ /**
6
+ * @internal
7
+ */
8
+ export interface ConnectionManagerSyncImplementationResult {
9
+ sync: StreamingSyncImplementation;
10
+ /**
11
+ * Additional cleanup function which is called after the sync stream implementation
12
+ * is disposed.
13
+ */
14
+ onDispose: () => Promise<void> | void;
15
+ }
16
+ /**
17
+ * @internal
18
+ */
19
+ export interface ConnectionManagerOptions {
20
+ createSyncImplementation(connector: PowerSyncBackendConnector, options: PowerSyncConnectionOptions): Promise<ConnectionManagerSyncImplementationResult>;
21
+ logger: ILogger;
22
+ }
23
+ type StoredConnectionOptions = {
24
+ connector: PowerSyncBackendConnector;
25
+ options: PowerSyncConnectionOptions;
26
+ };
27
+ /**
28
+ * @internal
29
+ */
30
+ export interface ConnectionManagerListener extends BaseListener {
31
+ syncStreamCreated: (sync: StreamingSyncImplementation) => void;
32
+ }
33
+ /**
34
+ * @internal
35
+ */
36
+ export declare class ConnectionManager extends BaseObserver<ConnectionManagerListener> {
37
+ protected options: ConnectionManagerOptions;
38
+ /**
39
+ * Tracks active connection attempts
40
+ */
41
+ protected connectingPromise: Promise<void> | null;
42
+ /**
43
+ * Tracks actively instantiating a streaming sync implementation.
44
+ */
45
+ protected syncStreamInitPromise: Promise<void> | null;
46
+ /**
47
+ * Active disconnect operation. Calling disconnect multiple times
48
+ * will resolve to the same operation.
49
+ */
50
+ protected disconnectingPromise: Promise<void> | null;
51
+ /**
52
+ * Tracks the last parameters supplied to `connect` calls.
53
+ * Calling `connect` multiple times in succession will result in:
54
+ * - 1 pending connection operation which will be aborted.
55
+ * - updating the last set of parameters while waiting for the pending
56
+ * attempt to be aborted
57
+ * - internally connecting with the last set of parameters
58
+ */
59
+ protected pendingConnectionOptions: StoredConnectionOptions | null;
60
+ syncStreamImplementation: StreamingSyncImplementation | null;
61
+ /**
62
+ * Additional cleanup function which is called after the sync stream implementation
63
+ * is disposed.
64
+ */
65
+ protected syncDisposer: (() => Promise<void> | void) | null;
66
+ constructor(options: ConnectionManagerOptions);
67
+ get logger(): ILogger;
68
+ close(): Promise<void>;
69
+ connect(connector: PowerSyncBackendConnector, options?: PowerSyncConnectionOptions): Promise<void>;
70
+ protected connectInternal(): Promise<void>;
71
+ /**
72
+ * Close the sync connection.
73
+ *
74
+ * Use {@link connect} to connect again.
75
+ */
76
+ disconnect(): Promise<void>;
77
+ protected disconnectInternal(): Promise<void>;
78
+ protected performDisconnect(): Promise<void>;
79
+ }
80
+ export {};
@@ -0,0 +1,175 @@
1
+ import { BaseObserver } from '../utils/BaseObserver.js';
2
+ /**
3
+ * @internal
4
+ */
5
+ export class ConnectionManager extends BaseObserver {
6
+ options;
7
+ /**
8
+ * Tracks active connection attempts
9
+ */
10
+ connectingPromise;
11
+ /**
12
+ * Tracks actively instantiating a streaming sync implementation.
13
+ */
14
+ syncStreamInitPromise;
15
+ /**
16
+ * Active disconnect operation. Calling disconnect multiple times
17
+ * will resolve to the same operation.
18
+ */
19
+ disconnectingPromise;
20
+ /**
21
+ * Tracks the last parameters supplied to `connect` calls.
22
+ * Calling `connect` multiple times in succession will result in:
23
+ * - 1 pending connection operation which will be aborted.
24
+ * - updating the last set of parameters while waiting for the pending
25
+ * attempt to be aborted
26
+ * - internally connecting with the last set of parameters
27
+ */
28
+ pendingConnectionOptions;
29
+ syncStreamImplementation;
30
+ /**
31
+ * Additional cleanup function which is called after the sync stream implementation
32
+ * is disposed.
33
+ */
34
+ syncDisposer;
35
+ constructor(options) {
36
+ super();
37
+ this.options = options;
38
+ this.connectingPromise = null;
39
+ this.syncStreamInitPromise = null;
40
+ this.disconnectingPromise = null;
41
+ this.pendingConnectionOptions = null;
42
+ this.syncStreamImplementation = null;
43
+ this.syncDisposer = null;
44
+ }
45
+ get logger() {
46
+ return this.options.logger;
47
+ }
48
+ async close() {
49
+ await this.syncStreamImplementation?.dispose();
50
+ await this.syncDisposer?.();
51
+ }
52
+ async connect(connector, options) {
53
+ // Keep track if there were pending operations before this call
54
+ const hadPendingOptions = !!this.pendingConnectionOptions;
55
+ // Update pending options to the latest values
56
+ this.pendingConnectionOptions = {
57
+ connector,
58
+ options: options ?? {}
59
+ };
60
+ // Disconnecting here provides aborting in progress connection attempts.
61
+ // The connectInternal method will clear pending options once it starts connecting (with the options).
62
+ // We only need to trigger a disconnect here if we have already reached the point of connecting.
63
+ // If we do already have pending options, a disconnect has already been performed.
64
+ // The connectInternal method also does a sanity disconnect to prevent straggler connections.
65
+ // We should also disconnect if we have already completed a connection attempt.
66
+ if (!hadPendingOptions || this.syncStreamImplementation) {
67
+ await this.disconnectInternal();
68
+ }
69
+ // Triggers a connect which checks if pending options are available after the connect completes.
70
+ // The completion can be for a successful, unsuccessful or aborted connection attempt.
71
+ // If pending options are available another connection will be triggered.
72
+ const checkConnection = async () => {
73
+ if (this.pendingConnectionOptions) {
74
+ // Pending options have been placed while connecting.
75
+ // Need to reconnect.
76
+ this.connectingPromise = this.connectInternal()
77
+ .catch(() => { })
78
+ .finally(checkConnection);
79
+ return this.connectingPromise;
80
+ }
81
+ else {
82
+ // Clear the connecting promise, done.
83
+ this.connectingPromise = null;
84
+ return;
85
+ }
86
+ };
87
+ this.connectingPromise ??= this.connectInternal()
88
+ .catch(() => { })
89
+ .finally(checkConnection);
90
+ return this.connectingPromise;
91
+ }
92
+ async connectInternal() {
93
+ let appliedOptions = null;
94
+ // This method ensures a disconnect before any connection attempt
95
+ await this.disconnectInternal();
96
+ /**
97
+ * This portion creates a sync implementation which can be racy when disconnecting or
98
+ * if multiple tabs on web are in use.
99
+ * This is protected in an exclusive lock.
100
+ * The promise tracks the creation which is used to synchronize disconnect attempts.
101
+ */
102
+ this.syncStreamInitPromise = new Promise(async (resolve, reject) => {
103
+ try {
104
+ if (!this.pendingConnectionOptions) {
105
+ this.logger.debug('No pending connection options found, not creating sync stream implementation');
106
+ // A disconnect could have cleared this.
107
+ resolve();
108
+ return;
109
+ }
110
+ if (this.disconnectingPromise) {
111
+ resolve();
112
+ return;
113
+ }
114
+ const { connector, options } = this.pendingConnectionOptions;
115
+ appliedOptions = options;
116
+ this.pendingConnectionOptions = null;
117
+ const { sync, onDispose } = await this.options.createSyncImplementation(connector, options);
118
+ this.iterateListeners((l) => l.syncStreamCreated?.(sync));
119
+ this.syncStreamImplementation = sync;
120
+ this.syncDisposer = onDispose;
121
+ await this.syncStreamImplementation.waitForReady();
122
+ resolve();
123
+ }
124
+ catch (error) {
125
+ reject(error);
126
+ }
127
+ });
128
+ await this.syncStreamInitPromise;
129
+ this.syncStreamInitPromise = null;
130
+ if (!appliedOptions) {
131
+ // A disconnect could have cleared the options which did not create a syncStreamImplementation
132
+ return;
133
+ }
134
+ // It might be possible that a disconnect triggered between the last check
135
+ // and this point. Awaiting here allows the sync stream to be cleared if disconnected.
136
+ await this.disconnectingPromise;
137
+ this.logger.debug('Attempting to connect to PowerSync instance');
138
+ await this.syncStreamImplementation?.connect(appliedOptions);
139
+ this.syncStreamImplementation?.triggerCrudUpload();
140
+ }
141
+ /**
142
+ * Close the sync connection.
143
+ *
144
+ * Use {@link connect} to connect again.
145
+ */
146
+ async disconnect() {
147
+ // This will help abort pending connects
148
+ this.pendingConnectionOptions = null;
149
+ await this.disconnectInternal();
150
+ }
151
+ async disconnectInternal() {
152
+ if (this.disconnectingPromise) {
153
+ // A disconnect is already in progress
154
+ return this.disconnectingPromise;
155
+ }
156
+ this.disconnectingPromise = this.performDisconnect();
157
+ await this.disconnectingPromise;
158
+ this.disconnectingPromise = null;
159
+ }
160
+ async performDisconnect() {
161
+ // Wait if a sync stream implementation is being created before closing it
162
+ // (syncStreamImplementation must be assigned before we can properly dispose it)
163
+ await this.syncStreamInitPromise;
164
+ // Keep reference to the sync stream implementation and disposer
165
+ // The class members will be cleared before we trigger the disconnect
166
+ // to prevent any further calls to the sync stream implementation.
167
+ const sync = this.syncStreamImplementation;
168
+ this.syncStreamImplementation = null;
169
+ const disposer = this.syncDisposer;
170
+ this.syncDisposer = null;
171
+ await sync?.disconnect();
172
+ await sync?.dispose();
173
+ await disposer?.();
174
+ }
175
+ }
@@ -2,10 +2,10 @@ import { Buffer } from 'buffer';
2
2
  import ndjsonStream from 'can-ndjson-stream';
3
3
  import Logger from 'js-logger';
4
4
  import { RSocketConnector } from 'rsocket-core';
5
- import { WebsocketClientTransport } from 'rsocket-websocket-client';
6
5
  import PACKAGE from '../../../../package.json' with { type: 'json' };
7
6
  import { AbortOperation } from '../../../utils/AbortOperation.js';
8
7
  import { DataStream } from '../../../utils/DataStream.js';
8
+ import { WebsocketClientTransport } from './WebsocketClientTransport.js';
9
9
  const POWERSYNC_TRAILING_SLASH_MATCH = /\/+$/;
10
10
  const POWERSYNC_JS_VERSION = PACKAGE.version;
11
11
  const SYNC_QUEUE_REQUEST_LOW_WATER = 5;
@@ -211,17 +211,12 @@ export class AbstractRemote {
211
211
  // headers with websockets on web. The browser userAgent is however added
212
212
  // automatically as a header.
213
213
  const userAgent = this.getUserAgent();
214
- let socketCreationError;
214
+ const url = this.options.socketUrlTransformer(request.url);
215
215
  const connector = new RSocketConnector({
216
216
  transport: new WebsocketClientTransport({
217
- url: this.options.socketUrlTransformer(request.url),
217
+ url,
218
218
  wsCreator: (url) => {
219
- const s = this.createSocket(url);
220
- s.addEventListener('error', (e) => {
221
- socketCreationError = new Error('Failed to create connection to websocket: ', e.target.url ?? '');
222
- this.logger.warn('Socket error', e);
223
- });
224
- return s;
219
+ return this.createSocket(url);
225
220
  }
226
221
  }),
227
222
  setup: {
@@ -243,11 +238,8 @@ export class AbstractRemote {
243
238
  rsocket = await connector.connect();
244
239
  }
245
240
  catch (ex) {
246
- /**
247
- * On React native the connection exception can be `undefined` this causes issues
248
- * with detecting the exception inside async-mutex
249
- */
250
- throw new Error(`Could not connect to PowerSync instance: ${JSON.stringify(ex ?? socketCreationError)}`);
241
+ this.logger.error(`Failed to connect WebSocket`, ex);
242
+ throw ex;
251
243
  }
252
244
  const stream = new DataStream({
253
245
  logger: this.logger,
@@ -196,11 +196,12 @@ The next upload iteration will be delayed.`);
196
196
  if (this.abortController) {
197
197
  await this.disconnect();
198
198
  }
199
- this.abortController = new AbortController();
199
+ const controller = new AbortController();
200
+ this.abortController = controller;
200
201
  this.streamingSyncPromise = this.streamingSync(this.abortController.signal, options);
201
202
  // Return a promise that resolves when the connection status is updated
202
203
  return new Promise((resolve) => {
203
- const l = this.registerListener({
204
+ const disposer = this.registerListener({
204
205
  statusUpdated: (update) => {
205
206
  // This is triggered as soon as a connection is read from
206
207
  if (typeof update.connected == 'undefined') {
@@ -209,12 +210,14 @@ The next upload iteration will be delayed.`);
209
210
  }
210
211
  if (update.connected == false) {
211
212
  /**
212
- * This function does not reject if initial connect attempt failed
213
+ * This function does not reject if initial connect attempt failed.
214
+ * Connected can be false if the connection attempt was aborted or if the initial connection
215
+ * attempt failed.
213
216
  */
214
217
  this.logger.warn('Initial connect attempt did not successfully connect to server');
215
218
  }
219
+ disposer();
216
220
  resolve();
217
- l();
218
221
  }
219
222
  });
220
223
  });
@@ -283,6 +286,7 @@ The next upload iteration will be delayed.`);
283
286
  */
284
287
  while (true) {
285
288
  this.updateSyncStatus({ connecting: true });
289
+ let shouldDelayRetry = true;
286
290
  try {
287
291
  if (signal?.aborted) {
288
292
  break;
@@ -295,12 +299,15 @@ The next upload iteration will be delayed.`);
295
299
  * Either:
296
300
  * - A network request failed with a failed connection or not OKAY response code.
297
301
  * - There was a sync processing error.
298
- * This loop will retry.
302
+ * - The connection was aborted.
303
+ * This loop will retry after a delay if the connection was not aborted.
299
304
  * The nested abort controller will cleanup any open network requests and streams.
300
305
  * The WebRemote should only abort pending fetch requests or close active Readable streams.
301
306
  */
302
307
  if (ex instanceof AbortOperation) {
303
308
  this.logger.warn(ex);
309
+ shouldDelayRetry = false;
310
+ // A disconnect was requested, we should not delay since there is no explicit retry
304
311
  }
305
312
  else {
306
313
  this.logger.error(ex);
@@ -310,8 +317,6 @@ The next upload iteration will be delayed.`);
310
317
  downloadError: ex
311
318
  }
312
319
  });
313
- // On error, wait a little before retrying
314
- await this.delayRetry();
315
320
  }
316
321
  finally {
317
322
  if (!signal.aborted) {
@@ -322,6 +327,10 @@ The next upload iteration will be delayed.`);
322
327
  connected: false,
323
328
  connecting: true // May be unnecessary
324
329
  });
330
+ // On error, wait a little before retrying
331
+ if (shouldDelayRetry) {
332
+ await this.delayRetry(nestedAbortController.signal);
333
+ }
325
334
  }
326
335
  }
327
336
  // Mark as disconnected if here
@@ -356,6 +365,9 @@ The next upload iteration will be delayed.`);
356
365
  let validatedCheckpoint = null;
357
366
  let appliedCheckpoint = null;
358
367
  const clientId = await this.options.adapter.getClientId();
368
+ if (signal.aborted) {
369
+ return;
370
+ }
359
371
  this.logger.debug('Requesting stream from server');
360
372
  const syncOptions = {
361
373
  path: '/sync/stream',
@@ -655,7 +667,25 @@ The next upload iteration will be delayed.`);
655
667
  // trigger this for all updates
656
668
  this.iterateListeners((cb) => cb.statusUpdated?.(options));
657
669
  }
658
- async delayRetry() {
659
- return new Promise((resolve) => setTimeout(resolve, this.options.retryDelayMs));
670
+ async delayRetry(signal) {
671
+ return new Promise((resolve) => {
672
+ if (signal?.aborted) {
673
+ // If the signal is already aborted, resolve immediately
674
+ resolve();
675
+ return;
676
+ }
677
+ const { retryDelayMs } = this.options;
678
+ let timeoutId;
679
+ const endDelay = () => {
680
+ resolve();
681
+ if (timeoutId) {
682
+ clearTimeout(timeoutId);
683
+ timeoutId = undefined;
684
+ }
685
+ signal?.removeEventListener('abort', endDelay);
686
+ };
687
+ signal?.addEventListener('abort', endDelay, { once: true });
688
+ timeoutId = setTimeout(endDelay, retryDelayMs);
689
+ });
660
690
  }
661
691
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Adapted from rsocket-websocket-client
3
+ * https://github.com/rsocket/rsocket-js/blob/e224cf379e747c4f1ddc4f2fa111854626cc8575/packages/rsocket-websocket-client/src/WebsocketClientTransport.ts#L17
4
+ * This adds additional error handling for React Native iOS.
5
+ * This particularly adds a close listener to handle cases where the WebSocket
6
+ * connection closes immediately after opening without emitting an error.
7
+ */
8
+ import { ClientTransport, Closeable, Demultiplexer, DuplexConnection, FrameHandler, Multiplexer, Outbound } from 'rsocket-core';
9
+ import { ClientOptions } from 'rsocket-websocket-client';
10
+ export declare class WebsocketClientTransport implements ClientTransport {
11
+ private readonly url;
12
+ private readonly factory;
13
+ constructor(options: ClientOptions);
14
+ connect(multiplexerDemultiplexerFactory: (outbound: Outbound & Closeable) => Multiplexer & Demultiplexer & FrameHandler): Promise<DuplexConnection>;
15
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Adapted from rsocket-websocket-client
3
+ * https://github.com/rsocket/rsocket-js/blob/e224cf379e747c4f1ddc4f2fa111854626cc8575/packages/rsocket-websocket-client/src/WebsocketClientTransport.ts#L17
4
+ * This adds additional error handling for React Native iOS.
5
+ * This particularly adds a close listener to handle cases where the WebSocket
6
+ * connection closes immediately after opening without emitting an error.
7
+ */
8
+ import { Deserializer } from 'rsocket-core';
9
+ import { WebsocketDuplexConnection } from 'rsocket-websocket-client/dist/WebsocketDuplexConnection.js';
10
+ export class WebsocketClientTransport {
11
+ url;
12
+ factory;
13
+ constructor(options) {
14
+ this.url = options.url;
15
+ this.factory = options.wsCreator ?? ((url) => new WebSocket(url));
16
+ }
17
+ connect(multiplexerDemultiplexerFactory) {
18
+ return new Promise((resolve, reject) => {
19
+ const websocket = this.factory(this.url);
20
+ websocket.binaryType = 'arraybuffer';
21
+ let removeListeners;
22
+ const openListener = () => {
23
+ removeListeners();
24
+ resolve(new WebsocketDuplexConnection(websocket, new Deserializer(), multiplexerDemultiplexerFactory));
25
+ };
26
+ const errorListener = (ev) => {
27
+ removeListeners();
28
+ // We add a default error in that case.
29
+ if (ev.error != null) {
30
+ // undici typically provides an error object
31
+ reject(ev.error);
32
+ }
33
+ else if (ev.message != null) {
34
+ // React Native typically does not provide an error object, but does provide a message
35
+ reject(new Error(`Failed to create websocket connection: ${ev.message}`));
36
+ }
37
+ else {
38
+ // Browsers often provide no details at all
39
+ reject(new Error(`Failed to create websocket connection to ${this.url}`));
40
+ }
41
+ };
42
+ /**
43
+ * In some cases, such as React Native iOS, the WebSocket connection may close immediately after opening
44
+ * without and error. In such cases, we need to handle the close event to reject the promise.
45
+ */
46
+ const closeListener = () => {
47
+ removeListeners();
48
+ reject(new Error('WebSocket connection closed while opening'));
49
+ };
50
+ removeListeners = () => {
51
+ websocket.removeEventListener('open', openListener);
52
+ websocket.removeEventListener('error', errorListener);
53
+ websocket.removeEventListener('close', closeListener);
54
+ };
55
+ websocket.addEventListener('open', openListener);
56
+ websocket.addEventListener('error', errorListener);
57
+ websocket.addEventListener('close', closeListener);
58
+ });
59
+ }
60
+ }