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
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
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"
}
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
{
"editions": [{
"edition": "trial",
"azureContainer": "js-solution-editions-trial",
"cdnPath": "<!-- PATH TO CDN TRIAL -->",
"revision": "0",
"pkgPath": "solution/trail/js-solution-editions.sppkg"
}, {
"edition": "lite",
"azureContainer": "js-solution-editions-lite",
"cdnPath": "<!-- PATH TO CDN LITE -->",
"revision": "1",
"pkgPath": "solution/lite/js-solution-editions.sppkg"
}, {
"edition": "full",
"azureContainer": "js-solution-editions-full",
"cdnPath": "<!-- PATH TO CDN TRIAL -->",
"revision": "2",
"pkgPath": "solution/full/js-solution-editions.sppkg"
}]
}
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
'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': '<!-- PATH TO CDN FULL -->',
'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);
gulp change-build-edition --edition lite
gulp bundle --ship
gulp package-solution --ship
gulp deploy-azure-storage
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!
Comments