From 05600c4f7d3535bc8a45b5d7ec19e5dc07a7642d Mon Sep 17 00:00:00 2001 From: Christian Wahle <59534030+bratelefant@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:23:33 +0100 Subject: [PATCH 1/2] - fix: memorize initial value of autoReconnect option - fix: race condition that might break a resuming session (#164) - add option.AsyncTokenStorage (eg. for using expos SecureStorage) - add userReady() method to determine, if the initial user object finished loading - add loginWithCustomHandler to be able to call custom Meteor loginHandlers --- lib/ddp.js | 8 +++- src/Meteor.js | 18 +++++++- src/user/User.js | 113 +++++++++++++++++++++++++++++++++++++---------- 3 files changed, 113 insertions(+), 26 deletions(-) diff --git a/lib/ddp.js b/lib/ddp.js index 267f98ee..a715dbfc 100644 --- a/lib/ddp.js +++ b/lib/ddp.js @@ -138,6 +138,8 @@ class DDP extends EventEmitter { // Default `autoConnect` and `autoReconnect` to true this.autoConnect = options.autoConnect !== false; this.autoReconnect = options.autoReconnect !== false; + // save the initial value of autoReconnect to be able to reset it + this._autoReconnectInitital = options.autoReconnect !== false; this.reconnectInterval = options.reconnectInterval || DEFAULT_RECONNECT_INTERVAL; @@ -203,7 +205,7 @@ class DDP extends EventEmitter { } /** - * Emits a new event. + * Emits a new event. Wraps emitting in a setTimeout (macrotask) * @override */ emit(...args) { @@ -213,8 +215,12 @@ class DDP extends EventEmitter { /** * Initiates the underlying websocket to open the connection + * If the connection was closed before eg. by calling `disconnect` + * from the client side, + * the value of `autoReconnect` will be reset to the initial value. */ connect() { + this.autoReconnect = this._autoReconnectInitital; this.socket.open(); } diff --git a/src/Meteor.js b/src/Meteor.js index 1c58421d..18d156cf 100644 --- a/src/Meteor.js +++ b/src/Meteor.js @@ -127,9 +127,15 @@ const Meteor = { } } + if (!options.AsyncTokenStorage) { + options.AsyncTokenStorage = options.AsyncStorage; + } + Data._endpoint = endpoint; Data._options = options; + this._reactiveDict.set('_userReady', false); + const ddp = new DDP({ endpoint: endpoint, SocketConstructor: WebSocket, @@ -140,6 +146,7 @@ const Meteor = { this.ddp = ddp; Data.ddp.on('connected', () => { + // Clear the collections of any stale data in case this is a reconnect if (Data.db && Data.db.collections) { for (var collection in Data.db.collections) { @@ -153,12 +160,12 @@ const Meteor = { if (this.isVerbose) { console.info('Connected to DDP server.'); } - this._loadInitialUser().then(() => { + User._loadInitialUser().then(() => { this._subscriptionsRestart(); }); this._reactiveDict.set('connected', true); this.connected = true; - Data.notify('change'); + Data.notify('change'); }); let lastDisconnect = null; @@ -179,6 +186,9 @@ const Meteor = { sub.readyDeps.changed(); } + // Mark user as not ready + this._reactiveDict.set('_userReady', false); + if (!Data.ddp.autoReconnect) return; if (!lastDisconnect || new Date() - lastDisconnect > 3000) { @@ -310,6 +320,7 @@ const Meteor = { } // Reconnect if we lose internet + NetInfo.addEventListener( ({ type, isConnected, isInternetReachable, isWifiEnabled }) => { if (isConnected && Data.ddp.autoReconnect) { @@ -321,7 +332,10 @@ const Meteor = { console.warn( 'Warning: NetInfo not installed, so DDP will not automatically reconnect' ); + Data.ddp.connect(); } + } else { + Data.ddp.connect(); } }, subscribe(name) { diff --git a/src/user/User.js b/src/user/User.js index 48262f23..b5393d64 100644 --- a/src/user/User.js +++ b/src/user/User.js @@ -32,6 +32,9 @@ const User = { const user = Users.findOne(user_id); return user && user._id; }, + userReady() { + return Meteor._reactiveDict.get('_userReady'); + }, _isLoggingIn: true, _isLoggingOut: false, loggingIn() { @@ -41,7 +44,7 @@ const User = { return User._isLoggingOut; }, logout(callback) { - this._isTokenLogin = false; + User._isTokenLogin = false; User._startLoggingOut(); Meteor.call('logout', (err) => { User.handleLogout(); @@ -51,15 +54,27 @@ const User = { }); }, handleLogout() { - Data._options.AsyncStorage.removeItem(TOKEN_KEY); + Data._options.AsyncTokenStorage.removeItem(TOKEN_KEY); Data._tokenIdSaved = null; + Meteor.isVerbose && + console.info('User.handleLogout()::: userId Saved: null'); this._reactiveDict.set('_userIdSaved', null); User._userIdSaved = null; User._endLoggingOut(); + }, + loginWithCustomHandler(req, callback) { + User._isTokenLogin = false; + User._startLoggingIn(); + + Meteor.call('login', req, (err, result) => { + User._handleLoginCallback(err, result); + + typeof callback == 'function' && callback(err); + }); }, loginWithPassword(selector, password, callback) { - this._isTokenLogin = false; + User._isTokenLogin = false; if (typeof selector === 'string') { if (selector.indexOf('@') === -1) selector = { username: selector }; else selector = { email: selector }; @@ -80,7 +95,7 @@ const User = { ); }, loginWithPasswordAnd2faCode(selector, password, code, callback) { - this._isTokenLogin = false; + User._isTokenLogin = false; if (typeof selector === 'string') { if (selector.indexOf('@') === -1) selector = { username: selector }; else selector = { email: selector }; @@ -129,6 +144,7 @@ const User = { Data.notify('loggingOut'); }, _endLoggingIn() { + Meteor._reactiveDict.set('_userReady', true); this._reactiveDict.set('_loggingIn', false); Data.notify('loggingIn'); }, @@ -145,17 +161,25 @@ const User = { 'id:', result.id ); - Data._options.AsyncStorage.setItem(TOKEN_KEY, result.token); + + Data._options.AsyncTokenStorage.setItem(TOKEN_KEY, result.token).catch( + (error) => { + console.warn( + 'AsyncStorage error: ' + error.message + ' while saving token' + ); + } + ); + Data._tokenIdSaved = result.token; this._reactiveDict.set('_userIdSaved', result.id); User._userIdSaved = result.id; User._endLoggingIn(); - this._isTokenLogin = false; + User._isTokenLogin = false; Data.notify('onLogin'); } else { Meteor.isVerbose && console.info('User._handleLoginCallback::: error:', err); - if (this._isTokenLogin) { + if (User._isTokenLogin) { setTimeout(() => { if (User._userIdSaved) { return; @@ -164,16 +188,33 @@ const User = { if (Meteor.user()) { return; } - User._loginWithToken(User._userIdSaved); + Meteor.isVerbose && + console.info( + 'User._handleLoginCallback::: trying again to login with ' + + Data._tokenIdSaved + + ' after ' + + this._timeout + + 'ms.' + ); + if (this._timeout > 10000) { + Meteor.isVerbose && + console.info( + 'User._handleLoginCallback::: 10000ms timeout exceeded, ending logging in.' + ); + User._endLoggingIn(); + Data.notify('onLoginFailure', err); + return; + } + User._loginWithToken(Data._tokenIdSaved); }, this._timeout); - } - // Signify we aren't logginging in any more after a few seconds - if (this._timeout > 2000) { + } else { + Meteor.isVerbose && + console.info( + 'User._handleLoginCallback::: not token login, ending logging in.' + ); User._endLoggingIn(); + Data.notify('onLoginFailure', err); } - User._endLoggingIn(); - // we delegate the error to enable better logging - Data.notify('onLoginFailure', err); } Data.notify('change'); }, @@ -192,11 +233,9 @@ const User = { } if (value !== null) { - this._isTokenLogin = true; + User._isTokenLogin = true; Meteor.isVerbose && console.info('User._loginWithToken::: token:', value); - if (this._isCallingLogin) { - return; - } + this._isCallingLogin = true; User._startLoggingIn(); Meteor.call('login', { resume: value }, (err, result) => { @@ -215,13 +254,30 @@ const User = { this._loadInitialUser(); }, time + 100); } else if (err?.error === 403) { + Meteor.isVerbose && + console.info( + 'User._handleLoginCallback::: token login failed, logging out.' + ); + Data.notify('onLoginFailure', err); + Data.notify('change'); User.logout(); } else { + Meteor.isVerbose && + console.info( + 'User._handleLoginCallback::: token login error and result:', + err, + result + ); User._handleLoginCallback(err, result); } }); } else { - Meteor.isVerbose && console.info('User._loginWithToken::: token is null'); + Meteor.isVerbose && + console.info( + 'User._loginWithToken::: token is null, ending logging in.' + ); + Data.notify('onLoginFailure', new Error("Token doesn't exist")); + Data.notify('change'); User._endLoggingIn(); } }, @@ -234,11 +290,22 @@ const User = { User._startLoggingIn(); var value = null; try { - value = await Data._options.AsyncStorage.getItem(TOKEN_KEY); - } catch (error) { - console.warn('AsyncStorage error: ' + error.message); - } finally { + value = await Data._options.AsyncTokenStorage.getItem(TOKEN_KEY).catch( + (error) => { + console.warn( + 'AsyncStorage error: ' + error.message + ' while loading token' + ); + } + ); + User._loginWithToken(value); + } catch (error) { + User._endLoggingIn(); + Data.notify('onLoginFailure', err); + Data.notify('change'); + console.warn( + 'AsyncStorage error: ' + error.message + ' while loading token' + ); } }, }; From fd161b92cba652284efd1dfc155fd9f32d7a4492 Mon Sep 17 00:00:00 2001 From: Christian Wahle <59534030+bratelefant@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:32:58 +0100 Subject: [PATCH 2/2] fix linting --- src/Meteor.js | 3 +-- src/user/User.js | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Meteor.js b/src/Meteor.js index 18d156cf..299788de 100644 --- a/src/Meteor.js +++ b/src/Meteor.js @@ -146,7 +146,6 @@ const Meteor = { this.ddp = ddp; Data.ddp.on('connected', () => { - // Clear the collections of any stale data in case this is a reconnect if (Data.db && Data.db.collections) { for (var collection in Data.db.collections) { @@ -165,7 +164,7 @@ const Meteor = { }); this._reactiveDict.set('connected', true); this.connected = true; - Data.notify('change'); + Data.notify('change'); }); let lastDisconnect = null; diff --git a/src/user/User.js b/src/user/User.js index b5393d64..be67ca64 100644 --- a/src/user/User.js +++ b/src/user/User.js @@ -62,7 +62,7 @@ const User = { User._userIdSaved = null; User._endLoggingOut(); - }, + }, loginWithCustomHandler(req, callback) { User._isTokenLogin = false; User._startLoggingIn(); @@ -70,8 +70,8 @@ const User = { Meteor.call('login', req, (err, result) => { User._handleLoginCallback(err, result); - typeof callback == 'function' && callback(err); - }); + typeof callback == 'function' && callback(err); + }); }, loginWithPassword(selector, password, callback) { User._isTokenLogin = false; @@ -235,7 +235,7 @@ const User = { if (value !== null) { User._isTokenLogin = true; Meteor.isVerbose && console.info('User._loginWithToken::: token:', value); - + this._isCallingLogin = true; User._startLoggingIn(); Meteor.call('login', { resume: value }, (err, result) => {