WebCake

About six months ago I started playing with Webpack in hopes to find a clean way to build multi-platform support into our code base at work. This was based on Webpack 1, and while I did find a way (and even wrote about it), I wasn’t entirely thrilled with how it worked at scale.

So I set back at it and found an alternate solution that I prefer and we’re now using in our enterprise apps at GE.

The Concept

Our web apps deploy to multiple platforms – web, hybrid mobile, and hybrid desktop. While the majority of our JavaScript (or TypeScript) files that power components, views, and interactions work the same across all platforms, some of them, and virtually all of our service layer, have different internal workings based on the platform of the deployment.

Example Use Case: Multi-platform data flow

The most prominent example in our apps at work is the differences in data flow between our web deployment and mobile deployment. We maintain the same interface for each implementation, so the call signatures remain the same. But the internal workings of each method are drastically different.

In web, our app runs over REST to reach our backend services. The GET and POST methods in our data.service.web.ts looks like this:

// in data.service.web.ts
export class DataService {

    constructor(private appConfig: AppConfig, private http: HTTP) {}

    getData(action: string): Observable<any> {
        const endpoint = this.appConfig.getMapping(action);
        return this.http.get(endpoint);
    }

    postData(action: string, payload: any): Observable<any> {
        const endpoint = this.appConfig.getMapping(action);
        return this.http.post(endpoint, payload);
    }
}

We pass in an action string that maps through an APP_CONFIG constant object to an endpoint in our backend, and if it’s a POST request, a corresponding POST body. You’ll see why we use actions in a second. Then we simply fire off the request directly and return the Observable.

In mobile, we go through a local database (specifically Couchbase) to act as a cache for all of our data calls, including GET and POST. If we’re offline, the cached request will hang out in our database until a connection is re-established. Once reconnected, our device database syncs with a master DB in the cloud, and our new DB entries – in the form of cached intentions to perform REST requests, are synced up. A service on the backend watches the database for new entries that match the cached REST request format, and parses them into actual REST requests and sends them on the user’s behalf.

So in mobile, we always go through the caching layer, and never actually reach out directly to the backend services. As a result, our mobile implementation of our DataService looks like this:

// in data.service.mobile.ts
export class DataService {

    constructor(private db: DataBaseService, private route: ActivatedRoute) {}

    getData(action: string): Observable<any> {
        this.db.set({
            type: 'command',
            action,
            params: this.route.snapshot.paramMap,
            time: Date.now()
        });
    }

    postData(action: string, body: any): Observable<any> {
        this.db.set({
            type: 'command',
            action,
            body,
            params: this.route.snapshot.paramMap,
            time: Date.now()
        });
    }
}

In this case, we’re using the same action string, but this time putting it into a database document that will sync up to the server. With the action and payload included, we can be sure that the service on the backend that will parse these documents with type: 'command', will have enough information to send off REST requests on our behalf, and stuff whatever new information comes back into the synced database, for us to have access to. The service on the backend has access to the same configuration mapping that the web deployment does – in fact they both get it from the same place. So regardless of what endpoint we’re hitting, or how that endpoint is structured, we can pass in an action name in both platforms and be sure our data calls end up in the right place.

So the challenge, then, is to set up platform-specific builds so that the right versions of these files are included in the right deployments.

File Extension Resolutions

The end result I’m looking for is to be able to create files that will be specific to a platform, as well as files that will be included regardless of the platform. Files that have simple interaction logic aren’t vary likely to have platform-specific functionality. So I don’t want to have to declare platform-specific versions of each one just to get them into the build, with duplicated code for every platform. That would be the worst.

However, when it is appropriate, I want to be able to import a file without the code ever really having to care which platform it’s running on. Take the following example:

import { DataService } from '../services/data.service';
import { ValidationService } from '../services/validation.service';

The goal here is for Webpack to resolve each file, regardless if it’s platform-specific or not. Ideally this means that when I introduce platform-specific functionality, I don’t have to change any import statements. The file that imports the two modules in the example above doesn’t need to know if either of those modules is platform-specific or not.

Platform as an Environment Variable

My Webpack config file expects a process.env.platform value to be set. This is as simple as adding to the execution of the NPM script when I run it in the command line:

$ platform=web npm run build

Alternatively, you could write your NPM script to specifically include a platform environment variable:

// in package.json
"scripts" : {
    "start": "platform=web ./node_modules/.bin/webpack-dev-server --config webpack.config.js"
}

Note that in Windows set ups, you might have to use the cross-env package to set your environment variables.

Finally, I can even ensure that it’s set with a default should no env var be passed in:

// in webpack.config.js
const platform = process.env.platform || 'web';

Webpack File Resolution

Webpack, by way of awesome, always had a built-in file extension resolution feature. Most of the time we generally only come across this setting when we add TypeScript, CoffeeScript, or other non-JS file types to our prject:

// in webpack.config.js
resolve: {
    extensions: ['.ts', '.js'],
    // ...other resolve settings
}

Webpack will look in this array when it finds an import or require statement that’s missing an extension. It will try to evaluate modules in the order in which they appear, meaning it’ll try to resolve a file ending in .ts first, followed by a module ending in .js.

But it’s a JS file right? And it can make use of Node’s JS functionality to evaluate a variable that contains a string. So with that in mind, let’s add our platform-specific extension into the mix:

// in webpack.config.js
resolve: {
    extensions: [`.${platform}.ts`, '.ts', '.js'],
    // ...other resolve settings
}

Now we have Webpack looking first to see if there’s a file that ends in .web.ts (or whatever platform we’ve passed in), before looking for a file that simply ends in .ts. That means that we can ensure that platform-specific files will always be resolved first. And since we’ve included .ts as a fallback, and non-platform-specific files will still be included in the build.

Loader File Resolution

Just like all of the Angular 2+ tutorials you’ll see around town, I originally started my projects with the awesome-typescript-loader, for the most part without any reason for doing so other than it’s what all the cool kids used.

However it doesn’t support configurable file extension resolution – or at least, it doesn’t at the time of this writing. So it wasn’t a big deal for me to switch to ts-loader, which does allow for resolving file extensions dynamically. That essentially gives my TypeScript loader the same extension resolution capabilities that are built into Webpack, but for TypeScript files only.

// in webpack.config.js
module: {
    rules: [
        {
            test: /\.ts$/,
            exclude: [/node_modules/],
            loaders: [{
                loader: 'ts-loader',
                options: {
                    resolve: {
                        extensions: [`.${platform}.ts`, '.ts']
                    }
                }
            }]
        }
        // ... all of the other rules
    ]
}

So now that both Webpack and my loader are configured to pull in the platform-specific version before trying for the generic version of a file, I now can target platform-specific builds simply by declaring an environment variable as part of my script execution. Nice.

Best Practices

Never explicitly list an extension

Webpack will already remind you that it’s a good idea to leave off the extensions when importing from another file. But in this case it’ll break the entire multi-platform flow of things if you overlook an inadvertent extension on a file path.

When you’re explicitly writing for multi-platform, it’s obvious. But much less obvious is when you’ve written a file for all-platform use (that is, ending in .ts), and then eventually broken it out to have platform-specific capabilities:

// that's bad, mm'kay?
import { SomeThing } from './some-file.ts';

When this happens you’ve already declared that the file will end in .ts, and Webpack won’t be able to find whatever platform-specific versions of it you might later create.

Make points of divergence as small as possible

Dudes and dudettes at work are still a little shaky as to where to draw the line between generic and platform-specific versions of files. From my point of view, it’s best to make a platform-specific module as small as humanly possible, when it makes sense, and keep the code that’s similar across all platforms written only once.

For example, if I have a service class that only has one point of differentiation between platforms – maybe a single method, or possibly a function called within a method – I’ll generally break that one bit of code out into a platform-specific file and import it into the main service file:

// in login.function.web.ts
export function login() {
    // do the web login
}
// in login.function.mobile.ts
export function login() {
    // do the mobile login
}
// in user.service.ts
import { login } from '../functions/login.function';

export class UserService {
    public login: Function;

    constructor() {
        this.login = login;
    }
}

On the other hand, when the entire service is different – as is the case with my platform-specific data flow – I’ll set up a platform version for the entire file. You can see an example of that at the top of this post.

Leave a Reply

Your email address will not be published. Required fields are marked *