8/23/2017

Handling Multiple Editions of SPFx Solution

In this post I want to explain an approach of how to handle (generate builds, update customers) multiple editions of the same SharePoint Framework solutions.
Use case for the approach:
You are an ISV and developing some product that has multiple editions, let's say, trial, lite, full. You want to have separate package file (sppkg) for each edition and also reference different CDNs based on the edition.
The approach I'll describe is not the only possible but it was successfully used for our own products.
Thinking about the problem in details we can point several key features that should be implemented:
  • When we're creating a new version we should create 3 separate sppkg files
  • Each sppkg file should contain manifest that references different CDN endpoints
  • It should be easy to upgrade customer from trial to lite and then to full; or directly from trial to full
  • You should know current edition in the code to execute the logic based on the edition's restrictions
Let's look at each point separately and describe what should be done to implement needed behavior.

When we're creating a new version we should create 3 separate sppkg files

The path to the sppkg file is configured in config/package-solution.json file in paths.zippedPackage property. By default, it contains solution/<name-of-the-solution>.sppkg which means that the file will be created in sharepoint/solution folder.
The first idea was to have different filename for each edition, like product-trial.sppkg, product-lite.sppkg and product-full.sppkg. But it will not work as you can't deploy both packages to App Catalog. You'll receive the exception that two different packages can't contain the same solution.
So the other approach is to create subfolders for each version, like solution/trial/product.sppkg, solution/lite/product.sppkg and solution/full/product.sppkg

Each sppkg file should contain manifest that references different CDN endpoints

CDN endpoint is config/write-manifests.json file in cdnBasePath property. And also in config/deploy-azure-storage.json in container property - this should be also taken into consideration if you're going to deploy the assets to the Azure.
So, we can change these properties to reference different CDN endpoints.

It should be easy to upgrade customer from trial to lite and then to full; or directly from trial to full

Currently app updates are done based on the value of solution.version property in config/package-solution.json file.
If you look at Apps for SharePoint list in the App Catalog you'll see that each deployed package (or app) has App Version value. If you install a newer version of the package SharePoint will mark the app as needed for update. And later a user can get newer version of the app on specific site (or the app will be updated everywhere automatically if tenant-scoped deployment is enabled.
The solution.version has 4-digit signature (as everything in Microsoft Universe): major.minor.build.revision. So, we can use revision to address each of our editions:
  • 0 for trial
  • 1 for lite
  • 2 for full
It will allow us to update any customer from, let's say, trial to full by providing a solution package that has the same major, minor and build digits but 2 instead on 0 in revision digit. As 2 > 0 it will mark the app as needed for update.

You should know current edition in the code to execute the logic based on the edition's restrictions

The idea here is to have some configuration file that is referenced in the code and the content if which differs for each edition. In our product we created a file custom-config.json in web part folder that contains JSON with current edition:
{
  "edition": "full"
}
And then the file is referenced in web part's code:
var customConfig: any = require('./custom-config.json');

//...

switch (customConfig.edition) {
  // implement needed cases
}

Combining all in one

We addressed all the points but still the process is totally manual. It would be great to automate needed changes. And it can be done with custom Gulp tasks.
First of all, let's create a json file to contain settings for each edition. Needed settings are:
  • edition name
  • azure container
  • CDN path
  • revision number (0, 1 or 2 in our case)
  • package path
Let's name the file build-config.json and add it to config folder. The content of the file should be like that:
{
  "editions": [{
    "edition": "trial",
    "azureContainer": "js-solution-editions-trial",
    "cdnPath": "",
    "revision": "0",
    "pkgPath": "solution/trail/js-solution-editions.sppkg"
  }, {
    "edition": "lite",
    "azureContainer": "js-solution-editions-lite",
    "cdnPath": "",
    "revision": "1",
    "pkgPath": "solution/lite/js-solution-editions.sppkg"
  }, {
    "edition": "full",
    "azureContainer": "js-solution-editions-full",
    "cdnPath": "",
    "revision": "2",
    "pkgPath": "solution/full/js-solution-editions.sppkg"
  }]
}
Now we can create a Gulp task that will take edition as a parameter and will modify configuration files base on edition settings. The task should be added to gulpfile.js before global initialization of the build (it means before line build.initialize(gulp)). You can read more about custom Gulp tasks in SPFx here.
The steps in the task will address all the points addressed above:
  • Modify config/package-solution.json to change revision number in solution.version property and also change paths.zippedPackage property. It worth saying that right now gulp package-solution fails if zippedPackage contains path to the folder that doesn't exist. That's why we'll need to additionally 'ensure' path to the sppkg. In our case we'll check if there is trail|lite|full subfolder in sharepoint/solution and create it if needed.
  • Modify config/write-manifests.json to change CDN endpoint url
  • Modify config/deploy-azure-storage.json to change Azure Storage container, and
  • Modify custom-config.json file in web part's source code folder to set current edition
Full code of gulpfile.js is listed below:
'use strict';

const gulp = require('gulp');
const build = require('@microsoft/sp-build-web');

const logging = require('@microsoft/gulp-core-build');
const fs = require('fs');

// path to editions config file
const buildConfigFilePath = './config/build-config.json';
// path to deploy-azure-storage.json
const azureConfigFilePath = './config/deploy-azure-storage.json';
// path to package-solution.json
const solutionConfigFilePath = './config/package-solution.json';
// path to write-manifests.json
const manifestFilePath = './config/write-manifests.json';
// path to custom-config.json that contains edition name to use in code
const customConfigFilePath = './src/webparts/helloWorld/custom-config.json';

// adding custom task. Can be executed with gulp change-build-edition --edition lite
build.task('change-build-edition', {
  execute: (config) => {
    return new Promise((resolve, reject) => {
      try {
        // getting edition parameter
        const edition = config.args['edition'] || 'full';

        // getting package-solution.json content
        const solutionJSON = JSON.parse(fs.readFileSync(solutionConfigFilePath));
        // getting deploy-azure-storage.json content
        const azureJSON = JSON.parse(fs.readFileSync(azureConfigFilePath));
        // getting write-manifests.json content
        const manifestJSON = JSON.parse(fs.readFileSync(manifestFilePath));
        // getting custom-config.json content
        const customConfigJSON = JSON.parse(fs.readFileSync(customConfigFilePath));
        // getting editions configurations
        const buildJSON = JSON.parse(fs.readFileSync(buildConfigFilePath));

        // getting edition settings by edition name
        const editionInfo = getEditionInfo(buildJSON, edition);

        if (!editionInfo) {
         resolve();
         return;
        }

        logging.log(`Configuring settings for edition: ${edition}`);

        //
        // updating custom-config.json file
        //
        customConfigJSON.edition = edition;
        logging.log('Updating custom config for the web part...');
        fs.writeFileSync(customConfigFilePath, JSON.stringify(customConfigJSON));

        //
        // updating package-solution.json
        //
        const revNumberStartIndex = solutionJSON.solution.version.lastIndexOf('.');
        // new version
        solutionJSON.solution.version = solutionJSON.solution.version.substring(0, revNumberStartIndex + 1) + editionInfo.revision;
        logging.log(`Checking if sppkg directory '${editionInfo.pkgPath}' exists and creating if not...`);
        // creating subfolder if doesn't exist
        ensurePath(editionInfo.pkgPath);
        // updating zippedPackage path
        solutionJSON.paths.zippedPackage = editionInfo.pkgPath;
        logging.log('Updating package-solution.json...');
        fs.writeFileSync(solutionConfigFilePath, JSON.stringify(solutionJSON));

        //
        // updating deploy-azure-storage.json
        //
        azureJSON.container = editionInfo.azureContainer;
        logging.log('Updating deploy-azure-storage.json...');
        fs.writeFileSync(azureConfigFilePath, JSON.stringify(azureJSON));

        //
        // updating write-manifests.json
        //
        manifestJSON.cdnBasePath = editionInfo.cdnPath;
        logging.log('Updating write-manifests.json...');
        fs.writeFileSync(manifestFilePath, JSON.stringify(manifestJSON));

        resolve();
      }
      catch (ex) {
        logging.log(ex);
        reject();
      }
    });
  }
});

/**
 * Gets edition settings by name
 * @param {any} buildJSON editions settings
 * @param {string} edition edition name
 */
function getEditionInfo(buildJSON, edition) {
  edition = edition || 'full';
  let result = null;

  if (buildJSON && buildJSON.editions && buildJSON.editions.length) {
    for (let i = 0, len = buildJSON.editions.length; i < len; i++) {
      const ver = buildJSON.editions[i];
      if (ver.edition === edition) {
        result = ver;
        break;
      }
    }
  }

  if (!result) {
    result = {
      'edition': 'full',
      'azureContainer': 'js-solution-editions-full',
      'cdnPath': '',
      'revision': '2',
      'pkgPath': 'solution/full/js-solution-editions.sppkg'
    };
  }

  return result;
}

/**
 * Ensures that the subfolders from the path exist
 * @param {string} path relative path to sppkg file (relative to ./sharepoint folder)
 */
function ensurePath(path) {
  if (!path) {
    return;
  }

  let pathArray = path.split('/');
  if (!pathArray.length) {
      return;
  }


  //
  // removing filename from the path
  //
  if (pathArray[pathArray.length - 1].indexOf('.') !== -1) {
      pathArray.pop();
  }

  //
  // adding sharepoint as a root folder
  //
  if (pathArray[0] !== 'sharepoint') {
      pathArray.unshift('sharepoint');
  }

  //
  // creating all subfolders if needed
  //
  let currPath = '.';

  for (let i = 0, length = pathArray.length; i < length; i++) {
    const pathPart = pathArray[i];
    currPath += `/${pathPart}`;

    if (!fs.existsSync(currPath)) {
      fs.mkdir(currPath);
    }
  }
}

build.initialize(gulp);
Now you can run this task as
gulp change-build-edition --edition lite
And then run out of the box
gulp bundle --ship
gulp package-solution --ship
gulp deploy-azure-storage
to package specific version.
After you run these for tasks for each edition you'll have 3 sppkg files in separate subfolder and also referencing CDN endpoints with different custom-config.json content. And consequently edition-based behavior.
Now you can deploy trial or lite edition to App Catalog and then easily upgrade to full.
Hope this post will help developing SPFx 3d party products.
Have fun!

No comments:

Post a Comment