Shipping code with CI
Continuous Integration allows Tatari developers ship code multiple times a day. For those unaccustomed to CI, it can be a freeing experience but as with most things, it’s not without its caveats. One unexpected challenge is keeping the web applications up-to-date in our customers’ browsers.
The Media Buying Platform team develops rich web apps for our buyers to locate and secure the best TV ad slots for Tatari’s clients. So, from the dev team’s perspective, the media buyers are our customers and our mission is to support them with improvements and fixes at a moment’s notice. CI makes this possible but… therein lies the rub:
Stale browser cache
When deploying a single page application (SPA), the typical approach is to ship each release bundle with a unique version hash in its name, forcing the browser cache to “bust” as soon as the user reopens the app (or hard-refreshes the browser).
However, if you happen to serve hard-working customers such as our media buyers, who often keep an instance of the SPA open while burning the midnight oil, they may miss out on bug fixes, cool new features, or worse, encounter bugs due to their app falling behind the deployed version. (While a rare event, we do occasionally break some eggs.)
Hence, we wanted some mechanism to alert our users directly within the app, whenever a new version becomes available.
Approach
After some internal brainstorming, we settled on the following approach:
At build time (Webpack):
- Associate a unique version identifier with each CI build
- Statically bake such version ID as a variable into the JS bundle
- Generate an
assets/build.json
file containing the same version ID to be deployed along with other assets
In the app (JS):
- Set up an interval to poll the server for the
build.json
file - Whenever the fetched version ID does not match the JS variable, notify the user
In essence, we are comparing the client bundle version against the latest version currently deployed on the production server.
Webpack config
Sounds easy enough? Sure does until one begins futzing with Webpack configurations. Thankfully, there are existing plugins and packages we can use.
For the unique version identifier, it makes sense to adopt the git version of the commit being built. To simplify this example, however, let’s use the uuid
package.
To bake the version into our JS code as a global variable, Webpack’s own DefinePlugin
is a shoo-in.
Finally, the third-party generate-json-webpack-plugin
NPM module will generate our JSON file.
yarn add -D uuid generate-json-webpack-plugin
With these dependencies in our package.json
, we can extend our webpack.config.js
:
// Imports...
const GenerateJsonPlugin = require('generate-json-webpack-plugin');
const { v4: uuid } = require('uuid');
// Your webpack shenanigans, whatever they may be...
// Create the JSON object with a unique version ID
const buildJson = { version: uuid() };
// Add your plugins...
module.exports.plugins = module.exports.plugins.concat([
// Bake in the ID as a global __VERSION__ var
new webpack.DefinePlugin({
__VERSION__: JSON.stringify(buildJson.version),
}),
// Save the JSON file wherever your deployable public assets folder lives
new GenerateJsonPlugin('../assets/build.json', buildJson),
]);
NOTE: If using Typescript, you will also need to declare the global __VERSION__
in one of your d.ts
files.
With the config stuff out of the way, we are now free to write some fun Javascript to act on any version changes.
The Notifier component
We need a little abstraction to do the polling and notifying for us. Luckily, we are still in the world of React, so a component will do. And while our Tatari APIs are GraphQL-powered, here we can settle for good old fetch
, e.g. the whatwg-fetch
polyfill. Finally, we can leverage our existing app-wide notifications (covered in our earlier post):
import 'whatwg-fetch';
import * as React from 'react';
import useInterval from 'hooks/use_interval';
import useNotifications, {NOTIFICATION} from 'hooks/use_notifications';
const FETCH_INTERVAL = 1000 * 60 * 30; // 30 mins (or as appropriate)
type TVersionFile = {
builtAt: number;
version: string;
};
export default function VersionNotifier() {
const {notifications, setNotification} = useNotifications();
// Polling handler
const handleVersionNotify = React.useCallback(async () => {
try {
// Fetch build.json from server
const verFile: TVersionFile = await fetch('/assets/build.json').then(resp => resp.json());
// On version ID mismatch, trigger notification
if (verFile.version !== __VERSION__) {
setNotification({
message: `New app version available. Please refresh your browser!`,
variant: NOTIFICATION.ALERT,
timeout: null, // do not auto-expire the message
});
}
} catch (e) {
console.error('Unable to fetch build.json');
}
}, [notifications, setNotification]);
// Set up polling, call above handler at each interval
useInterval(handleVersionNotify, FETCH_INTERVAL);
// Render an empty span (for some debugging fun)
return <span data-build-version={__VERSION__} />;
}
NOTE: To learn more about the useInterval
and useNotification
hooks above, explore the App-wide Notifications examples on CodeSandbox.
That’s pretty much it. One neat UX enhancement worth suggesting might be an actual link within the notification message to reload the app. Since the message
prop accepts JSX, easily done:
setNotification({
message: (
<>
New app version available. Please{' '}
<button onClick={() => window.location.reload()}>refresh</button>
{' '}your browser!
</>
),
variant: NOTIFICATION.ALERT,
timeout: null, // do not expire
});
Now, slot the <VersionNotifier />
near the root of your render tree (below the <NotificationsProvider />
) and your customers will never miss an update!
Bells & Whistles
As additional improvements, our version is applied conditionally for production builds-only and implements an extra debounce-like timeout to shield our buyers from “notification burnout”. So, on busy days, perhaps they get notified on every second or third deployment only.
Conclusion
Did I mention we ship a LOT? Come join us at Tatari and you will too!