diff --git a/docs/meteor-connectivity.md b/docs/meteor-connectivity.md new file mode 100644 index 0000000..b98d33a --- /dev/null +++ b/docs/meteor-connectivity.md @@ -0,0 +1,214 @@ +# Meteor Connectivity Simulation + +For folks coming from the classic Meteor DDP environment, we are very used to reactivity on the connection to the server. For instance, when we connect to Meteor using DDP, we automatically have: + +* Reactive connectivity status to server and event notification when that status changes. This helps us do things like navigating to splash-screen/login/post-login pages. +* Auto-login attempt when connection established + +You can still achieve that with this package, though there is a bit of extra setup. + +## Meteor Connectivity Status Simulation + +The standard Meteor/DDP behaviour is when the DDP over websocket connection is established, you get ```Meteor.status().connected``` and ```Meteor.user()```. When the link status changes, or the user logs in/out, you get reactive updates. To get such behaviour with this package, you can register handlers for onConnected, onReconnected, and onDisconnected events from the ```wsLink``` apollo-link that we created. + +```js + +let linkBounce = false; // this is to handle intentional bouncing of our link + +// our websocket link for subscriptions +const wsLink = new WebSocketLink({ + uri: constants.GRAPHQL_SUBSCRIPTION_ENDPOINT, + options: { + reconnect: true, + connectionParams: () => ( // a promise that resolves to return the loginToken + new Promise((resolve, reject) => { // eslint-disable-line no-undef,no-unused-vars + getLoginToken().then(token => { + if (token) { + console.log("wsLink loginToken = " + token); + resolve({ + [constants.AUTH_TOKEN_KEY]: token + }); + } else { + resolve({ + [constants.AUTH_TOKEN_KEY]: "" + }); + } + }); + })) + } +}); + +function _connected() { + console.log("WE ARE CONNECTED!"); + + // do something here, say check user login status or navigate to appropriate page + +} + +function _disconnected() { + console.log("WE ARE DISCONNECTED!"); + + // if we are intentionally bouncing our link, don't do anything + if (!linkBounce) { + + // do something here, like flush all subscriptions or navigate to a different page + + } + + // reset link bounce flag + linkBounce = false; + +} + +function setupServerConnectionListeners() { + // set some listeners for websocket connection status + wsLink.subscriptionClient.onConnected(_connected); + wsLink.subscriptionClient.onReconnected(_connected); + wsLink.subscriptionClient.onDisconnected(_disconnected); + + // bounce websocket link in subscription client to reconnect so that our listeners are active + console.log("bouncing websocket link"); + linkBounce = true; + wsLink.subscriptionClient.close(false, false); + +} +``` + +Notice that in our ```setupServerConnectionListeners()``` function, we bounce the link after we set up the listeners. This is because the websocket connection is established when you create the wsLink and the listeners will not be active yet. We bounce the link to activate the listeners. Also notice that we don'd do anything in our ```_disconnect()``` handler when we are intentionally bouncing the link. + +You should be able to test this now by bringing either the server or client up and down, and you should observer Meteor like connection reactivity. + +Keep in mind that the subscriptions-transport-ws uses a backoff system to reconnect to the server, so you might experience a few seconds of delay before the link is reestablished when the server goes down and comes back up. There should be no delay when the client goes down and comes back up. + +## Auto-login of User + +Meteor users are also used to have the system auto-login the user if there is a valid login token, and having a ```Meteor.user()```. We can get this type of behaviour by setting up a query to retrieve user information. Let's call it a ```whoAmI``` query. Obviously, we need to set this schema and resolver on the server: + +### Server + +Let's set up the schema first: + +```graphql +# file user.graphql + +type Email +{ + address: String + verified: Boolean +} + +type User @mongo(name: "users") +{ + _id: ID! + emails: [Email] + firstName: String + lastName: String + fullName: String +} + +type Query { + users: [User] + me: User # This is our user to query on! +} +``` + +Next, our resolver: + +```js +// file user.resolver.js + +import { Meteor } from "meteor/meteor"; + +export default { + User: { + fullName(user) { + return (`${user.profile.firstName} ${user.profile.lastName}`); + }, + firstName(user) { + return (`${user.profile.firstName}`); + }, + lastName(user) { + return (`${user.profile.lastName}`); + } + }, + Query: { + users(_, args, {db}, ast) { + // use grapher to do the query instead of direct mongo query + let allUsers = db.users.astToQuery(ast, { + embody({body, getArgs}) { // eslint-disable-line no-unused-vars + // grab profile too if not already included + if (!body.profile) { + Object.assign(body, { + profile: 1 + }); + } + } + }).fetch(); + return allUsers; + }, + // this is our 'me' query resolver + me(_, args, context) { + return Meteor.users.findOne(context.userId); // notice we look for the userId, so we are looking for a valid login token + } + } +}; +``` + +### Client + +Now that we have set up our server, we can query for the user once the websocket connection has been established. So we change our ```_connected()``` connection handler to: + +```js +import gql from "graphql-tag"; + +// checks user info +function whoAmI() { + const myInfo = gql` + query { + me { + _id + firstName + lastName + fullName + } + }`; + + return new Promise((resolve, reject) => { // eslint-disable-line no-undef + // execute the query + client.query({ + query: myInfo, + fetchPolicy: "no-cache" // we don't want to cache the results + }).then((result) => { + console.log("user query success: " + JSON.stringify(result)); + resolve(result.data); + }).catch((error) => { + console.log("user query error: " + error); + reject(null); + }); + }); +} + +function _connected() { + console.log("WE ARE CONNECTED!"); + + // check our login status + whoAmI().then((data) => { + if (data && data.me) { + console.log("we are already logged in!"); + // we have a valid login token, do something, maybe go to post-login page + // we also have a valid userId in data.me._id, so we could use that elsewhere + } else if (data && !data.me) { + console.log("we are not logged in!"); + // we do not have a valid login token, proceed to login page + } + }); +} +``` + +With this, once, the websocket connection has been established, it will do a http-link query to get the user information. This assumes the httpLink has been set up correctly. + +Keep in mind that the user infor you get back is not truly reactive like ```Meteor.user()```. This just tells you whether you have a valid login token or not. If you want to have your user information to be reactive, you need to set up a users subscription. + +With this, you have achieved some level of the Meteor connectivity reactiviy we have all gotten used to. Hopefully, this was useful! + + diff --git a/docs/react-native-client.md b/docs/react-native-client.md new file mode 100644 index 0000000..8950ed3 --- /dev/null +++ b/docs/react-native-client.md @@ -0,0 +1,249 @@ +# React-Native Client + +Things mostly should just work out of the box for a react-native client. Make sure you install the following packages: + +* apollo-cache-redux +* apollo-client +* apollo-link +* apollo-link-context +* apollo-link-error +* apollo-link-http +* apollo-link-ws +* apollo-live-client +* apollo-utilities +* core-js +* meteor-apollo-accounts +* react-apollo +* subscriptions-transport-ws + +If you don't use redux, install apollo-cache-inmemory instead of apollo-cache-redux, or any other cache implementation. + +There are, however, a few things to keep in mind: + +* Authentication token is usually stored in AsyncStorage, and retrieval of the token is an asynchronous process. +* The authorization header needs to set up appropriately for both http links and websocket links. +* Some polyfills are needed + +Since retrieval of the token is an asynchronous process, care must be taken to set it up appropriately for your http and websocket links. Let us first create a function to retrieve the token from AsyncStorage as a promise: + +```js +// our login token cache +let loginToken; + +// gets token from cache or from AsyncStorage +function getLoginToken() { + + return new Promise((resolve, reject) => { // eslint-disable-line no-undef + if (loginToken) { + console.log("resolving login token " + loginToken); + resolve(loginToken); + } else { + AsyncStorage.getItem(constants.AUTH_TOKEN_LOCALSTORAGE).then((token) => { + console.log("retrieved login token from AsyncStorage: " + token); + loginToken = token; + resolve(token); + }).catch(() => { + console.log("no login token found!"); + reject(""); + }); + } + }); + +} +``` + +Notice that we use a cache to avoid unnecessary lookups into AsyncStorage. We now need to provide this authorization token both your http and websocket links. + +## HTTP Link + +We will use ```setContext()``` from [apollo-link-context](https://github.com/apollographql/apollo-link/tree/master/packages/apollo-link-context) to set the authorization header: + +```js +// our http link +const httpLink = createHttpLink({ + uri: constants.GRAPHQL_ENDPOINT +}); + +const AUTH_TOKEN_KEY = "meteor-login-token"; + +// create a link to insert the authorization header for http(s) +const authorizationLink = setContext(operation => getLoginToken().then(token => { // eslint-disable-line no-unused-vars + return { + // set meteor token here + headers: { + [AUTH_TOKEN_KEY]: token || null + } + }; +}) +); + +// create our query/mutation link which uses http(s) +const queryLink = ApolloLink.from([authorizationLink, httpLink]); +``` + +Now, every time the http link is used, it will query the latest login token. This will happen even if the login token changes. + +## WebSocket Link + +Things are slightly more complicated for websocket links which are required for subscriptions: + +* First, [apollo-link-ws](https://github.com/apollographql/apollo-link/tree/master/packages/apollo-link-ws) uses [subscriptions-transport-ws](https://github.com/apollographql/subscriptions-transport-ws), the latest version of which (0.9.9 as of this writing) does not support an asynchronous call to set connection parameters headers with the authorization token. +* Second, the token is sent only once when the connection is established, so if/when the token changes, the connection needs to be reestablished. + +For the first issue, we need to patch [subscriptions-transport-ws](https://github.com/apollographql/subscriptions-transport-ws). Here is the patch: + +```diff +patch-package +--- a/node_modules/subscriptions-transport-ws/dist/client.js ++++ b/node_modules/subscriptions-transport-ws/dist/client.js +@@ -380,9 +380,11 @@ var SubscriptionClient = (function () { + _this.clearMaxConnectTimeout(); + _this.closedByUser = false; + _this.eventEmitter.emit(_this.reconnecting ? 'reconnecting' : 'connecting'); +- var payload = typeof _this.connectionParams === 'function' ? _this.connectionParams() : _this.connectionParams; +- _this.sendMessage(undefined, message_types_1.default.GQL_CONNECTION_INIT, payload); +- _this.flushUnsentMessagesQueue(); ++ var promise = Promise.resolve(typeof _this.connectionParams === 'function' ? _this.connectionParams() : _this.connectionParams); ++ promise.then(payload => { ++ _this.sendMessage(undefined, message_types_1.default.GQL_CONNECTION_INIT, payload); ++ _this.flushUnsentMessagesQueue(); ++ }) + }; + this.client.onclose = function () { + if (!_this.closedByUser) { +``` + +You can use the excellent [patch-package](https://github.com/ds300/patch-package) to automatically apply this patch whenever you install node_modules with either "yarn" or "npm install". + +Once the patch has been applied, you can set up the WebSocketLink to use an asynchronous function to set up the authorization header: + +```js +// our websocket link for subscriptions +const wsLink = new WebSocketLink({ + uri: constants.GRAPHQL_SUBSCRIPTION_ENDPOINT, + options: { + reconnect: true, + connectionParams: () => ( // a promise that resolves to return the loginToken + new Promise((resolve, reject) => { // eslint-disable-line no-undef,no-unused-vars + getLoginToken().then(token => { + if (token) { + console.log("wsLink loginToken = " + token); + resolve({ + [constants.AUTH_TOKEN_KEY]: token + }); + } else { + resolve({ + [constants.AUTH_TOKEN_KEY]: "" + }); + } + }); + })) + } +}); +``` + +Now, the login token is resolved and sent to the server when the websocket connection is established. + +However, we still need address the second issue where the authorization token is sent only once and not when the token changes, e.g. during logout/login. To handle this, we essentially need to bounce the websocket link: + +```js +wsLink.subscriptionClient.close(false, false); +``` + +This needs to be done at an appropraiate place, such when your client receives a new login token. If you are using [meteor-apollo-accounts](https://github.com/cult-of-coders/meteor-apollo-accounts), then the loginToken change will happen in the ```setTokenStore()``` implementation: + +```js +import { loginWithPassword, onTokenChange, setTokenStore } from "meteor-apollo-accounts"; +import { getMainDefinition } from "apollo-utilities"; + +const AUTH_TOKEN_KEY = "meteor-login-token"; +const AUTH_TOKEN_LOCALSTORAGE = "Meteor.loginToken"; +const AUTH_TOKEN_EXPIRY = "Meteor.loginTokenExpires"; +const AUTH_USER_ID = "Meteor.userId"; + +const link = split(({query}) => { + const {kind, operation} = getMainDefinition(query); + return kind === "OperationDefinition" && operation === "subscription"; +}, wsLink, queryLink); + +// our apollo client +const client = new ApolloClient({ + link, + cache +}); + +// override setTokenStore to store login token in AsyncStorage +setTokenStore({ + set: async function ({userId, token, tokenExpires}) { + console.log("setting new token " + token); + loginToken = token; // update cache + await AsyncStorage.setItem(AUTH_USER_ID, userId); + await AsyncStorage.setItem(AUTH_TOKEN_LOCALSTORAGE, token); + // AsyncStorage doesn't support Date type so we'll store it as a String + await AsyncStorage.setItem(AUTH_TOKEN_EXPIRY, tokenExpires.toString()); + if (token) { + // we have a valid login token, reset apollo client store + client.resetStore(); + // bounce the websocket link so that new token gets sent + linkBounce = true; + console.log("bouncing websocket link"); + wsLink.subscriptionClient.close(false, false); + } + }, + get: async function () { + return { + userId: await AsyncStorage.getItem(AUTH_USER_ID), + token: await AsyncStorage.getItem(AUTH_TOKEN_LOCALSTORAGE), + tokenExpires: await AsyncStorage.getItem(AUTH_TOKEN_EXPIRY) + }; + } +}); + +// callback when token changes +onTokenChange(function() { + console.log("token did change"); + client.resetStore(); // client is the apollo client instance +}); +``` + +One thing to note is that, don't start a new subscription immediately after bouncing the link. Wait for the connection to be established before doing so. You can do that in the onConnected()/onReconnected() handlers: + +```js +function _connected() { + console.log("WE ARE CONNECTED!"); + // do someting here... +} + +wsLink.subscriptionClient.onConnected(_connected); +wsLink.subscriptionClient.onReconnected(_connected); +``` + +## Polyfills + +On react-native, there are some other quirky issues. The meteor-apollo-accounts makes use of the Symbol type which is not supported on Android. A polyfille is required to handle this: + +```js +// import this since android needs this to resolve +// https://github.com/orionsoft/meteor-apollo-accounts/issues/73 +// solution was suggested here... +// https://github.com/facebook/react-native/issues/4676#issuecomment-163399041 +import "core-js/es6/symbol"; +import "core-js/fn/symbol/iterator"; +import "core-js/es6/set"; +``` + +Another polyfill is needed to handle Object.setProtoypeOf() which is used by apollo-client and not supported by android: + +```js +// This polyfill is to address an issue on android +// Object.setProtoypeOf is not defined on android +// https://github.com/apollographql/apollo-client/issues/3236 +Object.setPrototypeOf = Object.setPrototypeOf || function(obj, proto) { + obj.__proto__ = proto; // eslint-disable-line no-proto + return obj; +}; +``` + +Do both of these at the top of your main application file. + +This should get you functional on react-native! diff --git a/docs/table-of-contents.md b/docs/table-of-contents.md index 6e0abba..324e7d0 100644 --- a/docs/table-of-contents.md +++ b/docs/table-of-contents.md @@ -12,3 +12,5 @@ and doing a PR to fix it. Thank you! * [7. DDP](ddp.md) * [8. Visualising](visualising.md) * [9. Settings](settings.md) +* [10. React-Native Client Considerations](react-native-client.md) +* [11. Meteor Connectivty Simulation](meteor-connectivity.md) diff --git a/docs/visualising.md b/docs/visualising.md index 036bf1f..f6249f4 100644 --- a/docs/visualising.md +++ b/docs/visualising.md @@ -3,7 +3,7 @@ Various ways to visualise your GraphQL Schema with ease: ``` -graphqlviz https://localhost:3000 | dot -Tpng -o graph.png +graphqlviz https://localhost:3000/graphql | dot -Tpng -o graph.png ``` ## Graphqlviz