We came across a new use case and I have found no proper way to solve this. We basically want to provide the latest official build of our mobile app in the play store for everyone to install. However, we host several instances of our app and our users can also host on premise. Thus, users require first to connect to the correct server before logging in. How can can we achieve this in the mobile app without introducing DDP.connect (which requires lots of extra work with accounts, token etc.)? I checked on Rocket.Chat because their use case is similar (one mobile app, multiple possible servers to connect) but they realized it using a native Android app. Any experiences on this?
I’ve been waiting to see if anyone responds, but since they haven’t yet…
Using DDP.connect isn’t that difficult. I’ve recently done this as discussed here: Dynamically change mobile-server variable. I’m happy to publicly share more of my code if anyone wants it. I think each situation is too different to make a package out of it though - and this is something each developer should think about themselves rather than leave to a package.
Some other things to consider:
-
How will the client determine which server to connect to? In my case it was simple, anyone with an @act.gov.au email address was connected to my au.* subdomain server hosted in Sydney. Your case sounds more difficult - user drop down?
-
Email verification/reset-password routes - the links in the verification email needs to point to the correct server, or include a query parameter telling the client which server to connect to before testing the verification token.
-
Hot Code Push - do you intend on updating the user’s app via HCP? If so, you need to maintain the
meteor_autoupdate_clientVersions
subscription on the original server (see link above). -
App Intents - the Android app will only be able to connect to servers which are whitelisted in the
mobile-config.js
usingApp.accessRule()
(in my tests the iOS apps didn’t seem to care). If the users can host their own solution, how can you whitelist their domains? You’d have to upload a new app to the play store. Or you could use a wildcard subdomain on your main domain and set up a DNS record which points the subdomain to the client’s domain - basically make them ‘buy’ a private subdomain from you! -
Webhooks - do you use any webhooks for email bounce reports, automatic subscription renewals, incoming SMS messages, incoming emails, etc? If so, you either need separate accounts and webhooks for each domain, or your main server needs to forward the webhook requests to the correct secondary domain.
-
If the clients can host their own solution, who installs updates to the server code?
I’ve already solved most of these problems (albeit using only two hard-coded www. and au. subdomains, not variable domains) so let me know if you need any more assistance.
Thank you for your response and sharing your experience. I wanted to avoid DDP.connect
mainly because it introduced lots of problems with Accounts in a past project:
- we had to change the
_connection
for every created collection - we had to change the connection for
Accounts
- we had to create a new
Meteor.users
collection in order to make the loginWithPasswort method word - we came to the point where everything worked BUT because we change the
connection
on startup theAccounts
will check for the current user BEFORE the new connection has been initiated against the default server, which causes the current user to be logged out on every browser refresh.
It would be great if you share some code, or, if you’d like we could collaborate on some meteor-mobile-multi-server-boilerplate
project, so others of the community would benefit form it, too.
WDYT?
Click on the pencil icon at the top of the post, click back to the first edit, copy the original text. Then edit the post and paste in the original text.
Thank you, worked
No worries, I’m happy to share some more code. Getting late here in NZ though, time for bed. will put something together tomorrow…
OK, here’s my code with hopefully enough comments to explain what it’s doing:
/client/ddp.js
Meteor.LOCAL_STORAGE_REMOTE_URL_KEY = 'Meteor.remoteURL';
Meteor.LOCAL_STORAGE_REMOTE_TOKEN_KEY = 'Meteor.remoteLogintoken'
const _origSubscribe = Meteor.subscribe;
// this always points to this website/app's original server (could be au. or www. or localhost in development). No trailing slash
let homeURL = window.location.origin;
// this always points to the Sydney server, no trailing slash.
const remoteURL = "https://au.virtualinout.com";
// standardURL needs to point to our standard server (either www., or localhost during development, or staging server , etc)
// if this is the Sydney website then standardURL needs to be https://www.virtualinout.com/
// else standardURL needs to be the current window origin (which will be localhost in development)
const standardURL = homeURL==remoteURL ? "https://www.virtualinout.com" : homeURL;
// used for storing connections and collections
const stores = {};
let currentURL = homeURL;
// use this inside any autorun to force things like Meteor.user() to recompute when "Meteor" is changed to a different connection, or inside reactive render functions to let user know which server they are connected to
Meteor.connectionURL = new ReactiveVar(currentURL);
// we might already be connected to the remote server (web browser)
// in my implementation this doesn't need to be reactive
Meteor.isRemoteServer = currentURL==remoteURL;
// was the user previously logged into a remote server?
let newURL = Meteor._localStorage.getItem(Meteor.LOCAL_STORAGE_REMOTE_URL_KEY);
if (newURL) {
// if the user was previously logged in to a remote server, then on the default connection they will get automatically logged out
// wait for that to happen, then connect to remote server and log in again using the token
const remoteLoginToken = Meteor._localStorage.getItem(Meteor.LOCAL_STORAGE_REMOTE_TOKEN_KEY);
remoteConnect(newURL, () => {
if (remoteLoginToken) {
console.log('waiting to auto login in remote user...', remoteLoginToken, Meteor.loggingIn());
global.doWhenTrue(
() => !Meteor.loggingIn() && !Meteor.user(),
() => {
console.log('remote logging in', remoteLoginToken);
Meteor.loginWithToken(remoteLoginToken)
},
);
}
});
}
function remoteConnect(url, callback) {
console.log('remoteConnect', {url, currentURL});
if (url == currentURL) {
// already connected to correct server, execute callback.
callback && callback();
return true;
}
// remember current connection details so we can switch back to it
if (!stores[currentURL]) {
stores[currentURL] = {
connection: Meteor.connection,
users: Meteor.users,
// also remember all of our own collections...
companySettings,
teamsCollection,
chatsCollection,
}
}
currentURL = url;
Meteor.connectionURL.set(url);
Meteor.isRemoteServer = url == remoteURL;
if (url!=homeURL) {
Meteor._localStorage.setItem(Meteor.LOCAL_STORAGE_REMOTE_URL_KEY, url);
} else {
Meteor._localStorage.removeItem(Meteor.LOCAL_STORAGE_REMOTE_URL_KEY);
}
// create new connection, or re-use previous one...
const store = stores[url] || {};
const newConnection = store.connection || DDP.connect(url);
Accounts.connection = Meteor.connection = newConnection;
// point all these Meteor.functions to the new connection
['call', 'apply', 'status', 'reconnect', 'disconnect'].forEach(name => {
Meteor[name] = Meteor.connection[name].bind(Meteor.connection);
});
// do the same for Meteor.subscribe, except for one...
// ...'meteor_autoupdate_clientVersions' needs to stay on the original server for Hot Code Push to work
Meteor.subscribe = function() {
return arguments[0]=='meteor_autoupdate_clientVersions'
? _origSubscribe.apply(this, arguments)
: newConnection.subscribe.apply(newConnection, arguments);
}
// create new collections. Don't need {connection: newConnection} because Meteor.connection has also been changed
Meteor.users = Accounts.users = store.users || new Meteor.Collection('users');
// these are my own collections (which I store globally on the client and server):
global.companySettings = store.companySettings || new Mongo.Collection("companySettings");
global.teamsCollection = store.teamsCollection || new Mongo.Collection("teams");
global.chatsCollection = store.chatsCollection || new Mongo.Collection("chats");
if (!stores[url]) {
// For https://atmospherejs.com/mizzao/partitioner which is what I use for multi-tenancy
Partitioner.partitionCollection(chatsCollection);
Partitioner.partitionCollection(teamsCollection);
Partitioner.partitionCollection(companySettings);
// wait until connected then run the callback
callback && doWhenTrue(() => Meteor.status().connected, callback);
} else {
// In my implementation I leave the previous connection open
// 1) for Hot Code Push
// 2) so that I can log in/out of the remote/main server, but normal users wont need to do that.
// Can run the callback straight away because already connected
callback && callback();
}
return false;
}
Accounts.onLogin(() => {
if (Meteor.isRemoteServer) {
// make a copy of the login token whenever the user logs in...
Meteor._localStorage.setItem(Meteor.LOCAL_STORAGE_REMOTE_TOKEN_KEY, Meteor._localStorage.getItem('Meteor.loginToken'));
}
})
Accounts.onLogout(() => {
// clear our login token whenever the user logs out...
Meteor._localStorage.removeItem(Meteor.LOCAL_STORAGE_REMOTE_TOKEN_KEY);
})
// this function is used to connect to the correct server for a given email address
// since all my @act.gov.au users have to connect to the Sydney server.
// this could be more difficult if there isn't a direct relationship between email address and server...
Meteor.connectForEmail = function (email, callback) {
const isRemoteEmail = global.testRemoteEmailRegEx.test(email);
remoteConnect(isRemoteEmail ? remoteURL : standardURL, callback);
}
const _origLoginWithPassword = Meteor.loginWithPassword;
Meteor.loginWithPassword = function (options) {
Meteor.connectForEmail(options.email, () => {
_origLoginWithPassword.apply(this, arguments);
});
}
And here are some of my global functions/variables which are available on the client AND server
/lib/globals.js
// repeatedly call testFn until it returns true, then pass the result to callbackFn
doWhenTrue = (testFn, callbackFn, seconds) => {
const stopTime = new Date().addSecs(seconds||60);
const interval = Meteor.setInterval(() => {
const res = testFn();
if (res || new Date()>stopTime) Meteor.clearInterval(interval);
res && callbackFn(res);
}, 200);
return interval;
}
// this is very useful, and I don't like moment.js!
Date.prototype.addSecs = function(secs) {
var dat = new Date(this.valueOf()); // make a new date coz we can't change the original
dat.setSeconds(dat.getSeconds() + secs*1); // force days to be integer instead of string
return dat;
};
// log into the remote server for any of the following email addresses:
// *@act.gov.au
// myemail+au*@gmail.com <- for testing, gmail allows any myemail+anything@gmail.com
testRemoteEmailRegEx = /^(myemail\+au\w*@gmail.com|.*@act.gov.au)$/i;
Then, in any sign-in component all you need is Meteor.loginWithPassword
as normal because we’ve wrapped it with our own function to connect to the correct server first (assuming you have a simple relationship between email address and server…)
I think that’s pretty much it. Let me now if you have any questions…
Looking back through my code I’ve thought of an improvement which would make life easier, and also make it easier to package this so the user doesn’t have to update any other client code…
I create a new ReactiveVar
called Meteor.connectionURL
which I use in my UI code so that instead of
// these are reactive
user = Meteor.user();
status = Meteor.status();
I have to use:
// need to include Meteor.connectionId.get() to force the computation to rerun when we change Meteor.users and Meteor.status to something else
user = Meteor.connectionURL.get() && Meteor.user();
status = Meteor.connectionURL.get() && Meteor.status();
It might be easier to wrap all those reactive functions with a function which automatically calls Meteor.connectionURL.get()
before calling the original function, so that the computation automatically re-runs and Meteor.user()
can be used as normal. We’d also have to wrap every collection.find
and collection.findOne
to re-run whenever the connection changes.
Thanks a lot @wildhart this looks promising. I will try a first build and let you know if I run into any issues. I still think this could be a good start for a boilerplate, if all is running good.