@hal313/feature-switch-core

    1.0.3 • Public • Published

    feature-switch-core

    Build Status Dependency StatusDevDependency Status Gitpod ready-to-code

    A JavaScript synchronous implementation of feature switches, with a few bells and whistles. In general, features are describe as plain JavaScript objects whose values are boolean:

    const features = {
        featureOne: true,
        featureTwo: false
    };
    
    // Create an instance of a FeatureManager
    const featureManager = new FeatureManager(features);
    
    // Enable, disable and toggle a feature
    console.log('featureOne', featureManager.isEnabled('featureOne'));
    // output: featureOne true

    The FeatureManager object receives features at instantiation time; these features are cloned so that any changes to the initial feature object is not realized by the FeatureManager. Likewise any feature objects obtained from the FeatureManager are also clones and will not realize subsequent changes.

    As well, the FeatureManager will normalize the passed in features; any value which is the boolean true or the string literal "true" will be considered true and all other values will be considered false. It is possible to customize this behavior (this is covered in detail later).

    Advanced use scenarios support A/B testing and dark testing a new implemenation of some complex functionality (see examples below).

    In addition to the FeatureManager, there are facilities to strip out features from HTML, JavaScript and CSS. Such functionality may be useful at build time in order to provide different builds based on which features are enabled. This functionality could be leveraged in middleware. For example, an express server may filter HTML documents or JavaScript examples based on server-side features.

    Lastly, DOM management code is provided for supporting dynamic management of features within an HTML page.

    It is noteworthy that there are no runtime depedencies required for this library.

    Usage

    Usage examples are provided below.

    Basic Usage

    The most basic usage:

    // Define some features
    const features = {
        featureOne: true,
        featureTwo: false
    };
    
    // Create an instance of a FeatureManager
    const featureManager = new FeatureManager(features);
    
    // Enable, disable and toggle a feature
    console.log('featureOne', featureManager.isEnabled('featureOne'));
    // output: featureOne true
    //
    // Disable the feature
    featureManager.disable('featureOne');
    console.log('featureOne', featureManager.isEnabled('featureOne'));
    // output: featureOne false
    //
    // Enable the feature
    featureManager.enable('featureOne');
    console.log('featureOne', featureManager.isEnabled('featureOne'));
    // output: featureOne true
    //
    // Toggle the feature
    featureManager.toggle('featureOne');
    console.log('featureOne', featureManager.isEnabled('featureOne'));
    // output: featureOne false
    
    // Output the state of the feature:
    console.log('featureOne is enabled', featureManager.isEnabled('featureOne'));
    // output: featureOne is enabled false
    console.log('featureOne is disabled', featureManager.isDisabled('featureOne'));
    // output: featureOne is disabled true

    Advanced Usage

    Some advanced usage:

    // Define some features
    const features = {
        featureOne: true,
        featureTwo: false
    };
    
    // Create an instance of a FeatureManager
    const featureManager = new FeatureManager(features);
    
    // getFeatures() -> gets a copy of the features object
    console.log(featureManager.getFeatures().featureOne);
    // output: true
    
    // hasFeature(feature) -> determines if a feature is known to the feature manager
    console.log(featureManager.hasFeature('featureOne'));
    // output: true
    console.log(featureManager.hasFeature('invalidFeature'));
    // output: false
    
    // isAnyEnabled(features) -> returns true of any of the specified features are enabled
    console.log(featureManager.isAnyEnabled(['featureOne', 'featureTwo']));
    // output: true
    
    // isAllEnabled(features) -> returns true of all of the specified features are enabled
    console.log(featureManager.isAllEnabled(['featureOne', 'featureTwo']));
    // output: false
    
    // isAnyDisabled(features) -> returns true if any of the specified features are disabled
    console.log(featureManager.isAnyDisabled(['featureOne', 'featureTwo']));
    // output: true
    
    // isAllDisabled(features) -> returns true of all of the specified features are disabled
    console.log(featureManager.isAllDisabled(['featureOne', 'featureTwo']));
    // output: false
    
    // ifEnabled(feature, fn, args) -> executes a function if the specified feature is enabled
    featureManager.ifEnabled('featureOne', (words) => console.log(words.join(' ')), [['feature', 'one']]);
    // output: feature one
    
    // ifDisabled(feature, fn, args) -> executes a function if the specified feature is disabled
    featureManager.ifDisabled('featureTwo', (words) => console.log(words.join(' ')), [['feature', 'two']]);
    // output: feature two
    
    // decide(feature, enabledFn, disabledFn, enabledArgs, disabledArgs) -> ifEnabled and ifDisabled, all rolled up into one
    featureManager.decide('featureOne', () => console.log('enabled'), () => console.log('disabled'));
    // output: enabled
    featureManager.decide('featureTwo', () => console.log('disabled'), () => console.log('disabled'));
    // output: disabled
    
    // addFeature(feature, value) -> adds a feature to the feature manager
    featureManager.addFeature('featureThree', true);
    console.log(featureManager.isEnabled('featureThree'));
    // output: true
    
    // removeFeature(feature) -> removes a feature from the feature manager
    // NOTE: It is often not a good idea to remove features at runtime as this may cause some logical bugs
    featureManager.removeFeature('featureThree');
    console.log(featureManager.hasFeature('featureThree'));
    // output: false
    
    // setEnabled(feature, value) -> used by enable() and disable()
    featureManager.setEnabled('featureTwo', true);
    console.log(featureManager.isEnabled('featureTwo'));
    // output: true

    Feature state notification example:

    It may be useful to respond to feature state changes.

    // Define some features
    const features = {
        featureOne: true,
        featureTwo: false
    };
    
    // Create an instance of a FeatureManager
    const featureManager = new FeatureManager(features);
    const removeListener = featureManager.addChangeListener((featuresSnapshot, feature, value) => {
        console.log('feature', name, 'was changed to', value);
        removeListener();
    });
    featureManager.enable('featureTwo');
    // output: feature featureTwo was changed to true
    // NOTE: The return value of the function is a function which will remove the listener from
    //       the FeatureManager

    Function Generation

    Sometimes it is useful to create a function whose body will execute only when a specific feature is enabled or disabled:

    // Define some features
    const features = {
        featureOne: true
    };
    
    // Create an instance of a FeatureManager
    const featureManager = new FeatureManager(features);
    const ifFeatureOne = featureManager.ifFunction('featureOne', (name) => console.log(`featureOne is enabled, ${name}`));
    const elseFeatureOne = featureManager.elseFunction('featureOne', (name) => console.log(`featureOne is disabled, ${name}`));
    
    ifFeatureOne('Sam');
    elseFeatureOne('Mel');
    // output: featureOne is enabled, Sam
    
    featureManager.disable('featureOne');
    ifFeatureOne('Sam');
    elseFeatureOne('Mel');
    // output: featureTwo is disabled, Mel

    It is possible to combine the above two functions as one:

    // Define some features
    const features = {
        featureOne: true
    };
    
    // Create an instance of a FeatureManager
    const featureManager = new FeatureManager(features);
    const ifElseFeatureOne = featureManager.ifElseFunction(
        'featureOne',
        (name) => console.log(`featureOne is enabled, ${name}`),
        (name) => console.log(`featureOne is disabled, ${name}`)
    );
    
    ifElseFeatureOne('Sam');
    // output: featureOne is enabled, Sam
    
    featureManager.disable('featureOne');
    ifElseFeatureOne('Mel');
    // output: featureOne is disabled, Mel

    Context

    Sometimes it is required to extend some functionality of the FeatureManager. It is important to understand the Context object, how to use it and what can be done with it.

    The 'Context' object:

    {
        // This function will execute callbacks for ifEnabled(), ifDisabled() and decide()
        execute: (fn, args) => fn.apply({}, args),
        // This might be helpful in order to track how often callbacks are invoked
    
        // Determines if a value is true
        isTrue: (value) => true === value || 'true' === value,
        // Useful if other values should be considered true ('yes', 'on', etc.)
    
        // Determines if a feature is enabled
        isEnabled: (feature, features) => return features[feature],
        // Can be used to provide different features based on user (see example below)
        // NOTE: Use this with care
    
        // Determines if a feature can be set
        canSet: (feature, enabled) => true,
        // Sometimes it is not possible to alter feature states
    
        // Determines if features can be added
        canAddFeatures: () => return true,
        // Useful for read-only features
    
        // Determines if features can be removed
        canRemoveFeatures: () => return true
        // Useful for read-only features

    A Context object may be supplied to the FeatureManager at construction time. It is not required to provide all members; only the desired members.

    const isBeta = () => Math.random() < .5;
    
    // Define some features
    const features = {
        // The beta feature is disabled for everyone
        betaFeature: false
    };
    
    const context = {
        // Enable all features for beta users
        isEnabled: (feature, features) => isBeta()
    };
    
    // Create an instance of a FeatureManager
    const featureManager = new FeatureManager(features, context);
    
    console.log(featureManager.isEnabled('betaFeature'));
    // output: varies on the random value, but will be either true or false

    The FeatureManager API also has these functions, which dispatch to the Context.

    // Define some features
    const features = {
        featureOne: true,
        featureTwo: false
    };
    
    // Create an instance of a FeatureManager
    const featureManager = new FeatureManager(features);
    
    // Determines if features can be added
    console.log(featureManager.canAddFeatures());
    // output: true
    // Determines if features can be removed
    console.log(featureManager.canRemoveFeatures());
    // output: true
    // Determines if features can be set
    console.log(featureManager.canSetFeature('someFeature', true));
    // output: false (because feature 'someFeature' does not exist)
    
    // Checks to see if the feature can be enabled
    console.log(featureManager.canEnable('someFeature'));
    // output: false (because feature 'someFeature' does not exist)
    // Checks to see if the feature can be disabled
    console.log(featureManager.canDisable('someFeature'));
    // output: false (because feature 'someFeature' does not exist)
    // Checks to see if the feature can be toggled
    console.log(featureManager.canToggle('someFeature'));
    // output: false (because feature 'someFeature' does not exist)

    Example: Supporting Different API Version

    Sometimes it might be helpful to be able to feature switch API client libraries. This can be done like so:

    // Define some features
    const features = {
        apiV2: true
    };
    
    // Create an instance of a FeatureManager
    const featureManager = new FeatureManager(features);
    
    // Get API Client version 1, unless features.apiV2 is enabled
    const apiClient = featureManager.decide('apiV2', () => {return {version: 2}}, () => {return {version: 1}});
    console.log('apiClient', apiClient);

    Example: A/B Testing

    Using a custom Context can be used to support A/B testing.

    // Define some features
    const features = {
        abTestingFeatureX: true,
    };
    
    // Custom context
    const context = {
        // If the current user is enrolled in the alternate implementation, consider the features enabled
        isEnabled: (feature, features) => {
            // If the feature is an A/B testing feature, defer to the A/B testing decider
            if ('abTestingFeatureX' === feature) {
                // Use username sharding to dermine eligibility, for example: if the username starts with a-m, case insensitive
                // getUser().match(/^[a-mA-M]/);
                //
                // In this example, return true
                return true;
            }
    
            // Otherwise, return the default value
            return features[feature];
        }
    };
    
    // Create an instance of a FeatureManager
    const featureManager = new FeatureManager(features, context);
    
    // Test
    const result = featureManager.decide('abTestingFeatureX', () => 'implemenationA', () => 'implemenationB');
    console.log('result', result);
    // output: true

    Example: Dark Testing a new Implementation

    Testing a new implementation can introduce breaking changes. The FeatureManager can be used to allow the original implementation to continue to be used for all users, while also executing the new implementation for analysis, controlled by a feature switch.

    // Define some features
    const features = {
        darkTestImplementationA: true
    };
    
    /**
     * Executes a function, while keeping track of timing and results.
     *
     * @param {Function} fn the function to execute
     * @returns {Object} a descriptor of the execution results
     */
    const attempt = (fn) => {
        const attemptResult = {
            result: undefined,
            time: -1,
            thrown: false
        };
        let startTime;
        try {
            startTime = new Date().getTime();
            attemptResult.result = fn.apply({});
            attemptResult.time = new Date().getTime() - startTime;
        } catch (error) {
            attemptResult.thrown = error;
            attemptResult.time = new Date().getTime() - startTime;
        }
        return attemptResult;
    };
    
    /**
     * Evaluates the differences between the results of two different implementations.
     *
     * @param {Object} currentImplementation the current implementation
     * @param {Object} nextImplementation the implementation under test
     * @returns {any} the result of the current implementation execution
     */
    const evaluateMetrics = (currentImplementation, nextImplementation) => {
    
        // Attempt both implementations
        const currentResult = attempt(currentImplementation);
        const nextResult = attempt(nextImplementation);
    
        console.log('current implementation results', currentResult);
        console.log('next implementation results', nextResult);
        console.log('the quicker implementation', (currentResult.time < nextResult.time) ? 'current' : 'next');
        // Perhaps check that the results are the same, or that both implementations threw or did not throw
    
        // If the current implementation threw an error, then this function will propogate the error
        if (!!currentResult.thrown) {
            throw currentResult.thrown;
        }
    
        // Otherwise, return the result of the current implementation
        return currentResult.result
    };
    
    // Create an instance of a FeatureManager
    const featureManager = new FeatureManager(features);
    
    // If dark testing is enabled, then both the current and next implementations will be executed.
    const result = featureManager.decide('darkTestImplementationA', () => evaluateMetrics(() => 'currentImplementation result', () => 'nextImplementation result'), () => 'currentImplementation result')
    console.log('result', result);
    // output: varies based on which function executed the quickest

    Stripping Features

    The file feature-switch-strip exports a function (strip), which reads a string and attempts to strip out features as described the an options object. Features may be described by HTML comments, slash-style comments or star-style comments. Within HTML files, features may also be described in markup.

    It is important to note that the stripper is not a parser and therefore may act erratically when presented with complex configurations or situations. It is recommended to avoid embedded features all together, or to use the DOM manipulation functionality when managing complex DOM structures.

    Comments

    All comment types require two sets of comments, a start marker and an end marker. Having unbalanced blocks (i.e. missing end markers or end markers which are not in the correct order) may result in unexpected behavior. For this reason, it is recommended to be sure that both start and end marker blocks are formatted correctly and present in the correct place.

    HTML Comments

    HTML comments are generally found within HTML or XML files and look like:

    <!-- FEATURE.start(feature-name) -->
        <div>content for feature-name</div>
    <!-- FEATURE.end(feature-name) -->

    Slash

    Slash comments are generally found in JavaScript or LESS files:

    // FEATURE.start(feature-name)
    console.log('feature-name is enabled');
    // FEATURE.end(feature-name)

    Star

    Star comments are common in JavaScript as well as CSS:

    /* FEATURE.start(feature-name) */
    console.log('feature-name is enabled');
    /* FEATURE.start(feature-name) */

    HTML Elements

    In addition to comments in HTML files, some elements can also be configured to describe features. This functionality is experimental and may not work as desired. The DOM management functionality should produce more reliable results (as well as provide more options of specifying features).

    Feature Name as Element

    Using elements whose name is the feature name is supported.

    <feature-name>feature-name content</feature-name>

    Element with the "feature-name" Attribute

    Supports the feature-name attribute on elements. This will not work well with DOM elements of the same type within the content of the feature. The DOM management functionality should produce more reliable results (as well as provide more options of specifying features).

    <div feature-name="feature-name"></div>

    Options

    The strip function can take an optional second arguement, options. This is the structure of the options and represents the default options. Note that the replace attribute replaces disabled features and that ${FEATURE} will be replaced with the name of the feature being disabled.

    // The default options
    {
    
        // /* FEATURE.start(feature-name) */ ... /* FEATURE.end(feature-name) */
        starComments: {
            enabled: true,
            replace: '/* Feature [${FEATURE}] DISABLED */'
        },
        // feature.start(feature-name) -> // feature.end(feature-name)
        slashComments: {
            enabled: true,
            replace: '// Feature [${FEATURE}] DISABLED //'
        },
        // <!-- feature.start(feature-name) -->...<!-- feature.end(feature-name) -->
        htmlComments: {
            enabled: true,
            replace: '<!-- Feature [${FEATURE}] DISABLED -->'
        },
        // <feature-name ...></feature-name>
        htmlElements: {
            enabled: true,
            replace: '<!-- Feature [${FEATURE}] DISABLED -->'
        },
        // <div ... feature-name="feature-name" ...></div>
        htmlAttributes: {
            // This is experimental and probably only works on well formated HTML that is not complex and certainly not embedded elements, thank you very much
            enabled: true,
            replace: '<!-- Feature [${FEATURE}] DISABLED -->'
        }
    };

    DOM Manipulation

    Live DOM manipulation can be used to alter the DOM to show or hide DOM elements which represent features.

    HTML Comments

    HTML comments are generally found within HTML or XML files and look like:

    <!-- FEATURE.start(feature-name) -->
        <div>content for feature-name</div>
    <!-- FEATURE.end(feature-name) -->

    Feature Name as Element

    Using elements whose name is the feature name is supported.

    <feature-name>feature-name content</feature-name>

    Element with the "feature-name" Attribute

    Supports the feature-name attribute on elements.

    <div feature-name="feature-name"></div>

    Feature as Element with the "feature-name" Attribute

    Using elements of type feature and whose name is specified by the feature-name attribute are supported.

    <feature feature-name="feature-name">feature-name content</feature>

    Using the FeatureSwitchDOM Object

    The FeatureSwitchDOM is not aware of features per-se and instead operates solely on feature names. While simple cases may be serviced by the enable and disable functions, more complex cases should instead use syncToDom.

    // Import the class
    import { FeatureSwitchDOM } from './feature-switch-dom';
    
    // Instantiate the class
    const fsDom = new FeatureSwitchDOM();
    
    // Manipulate the DOM
    //
    // Enable all DOM elements described by the name "feature-one"
    fsDOM.enable('feature-one');
    
    // Disable all DOM elements described by the name "feature-one"
    fsDOM.disable('feature-one`);
    
    // Synchronize all DOM elements to the specified features
    fsDOM.syncToDom({'feature-one': true, 'feature-two': false});

    It is more common to use the FeatureSwitchDOM class with a FeatureManager instance:

    // Import the class
    import { FeatureSwitchDOM } from './feature-switch-dom';
    import { FeatureManager } from './feature-manager';
    
    // The features
    const features = {
        featureone: true,
        featuretwo: false,
        featurethree: true,
        featurefour: false
    };
    
    // Instantiate the DOM management
    const fsDom = new FeatureSwitchDOM();
    
    // The FeatureManager instance
    const featureManager = new FeatureManager(features);
    
    // Enable a feature
    featureManager.enable('featuretwo');
    
    // Sync the DOM
    fsDom.syncToDom(featureManager.getFeatures());

    Automatically managing the HTML can be accomplished fairly easily.

    import { FeatureSwitchDOM } from './feature-switch-dom.js';
    import { FeatureManager } from './feature-manager.js';
    
    // The features
    const features = {
        featureone: true,
        featuretwo: false,
        featurethree: true,
        featurefour: false
    };
    
    // The FeatureManager instance
    const featureManager = new FeatureManager(features);
    
    // The FeatureSwitchDOM instance
    const fsDom = new FeatureSwitchDOM(featureManager.getFeatures());
    
    // The FeatureSwitchDOM instance will sync the features to the DOM every time the features change
    featureManager.addChangeListener((featureSnapshot, feature, enabled) => fsDom.syncToDom(featureManager.getFeatures(featureSnapshot)));

    Custom Handlers

    The FeatureSwitchDOM methods take optional handlers which can be used to change how a DOM element is rendered when enabled or disabled. The signature is:

    /**
     * @param {Node} node the node being managed
     * @param {String} feature the feature
     * @param {boolean} enabled the state of the feature
     */
    const handler = (node, feature, enabled) => {};

    NOTE: Because the FeatureSwichDOM is a parser and the stripper is not a parser, achieving the same functionality between the two is not possible (in particular, the stripper does not support the same functionality as the DOM management functionality). However, both the stripper and the DOM management functionality support HTML comments. For this reason, it is recommended to use strictly HTML comments if consistent behavior is desired across the stripper and the DOM management functionality.

    Developing

    To setup a development environment:

    ## Get the dev deps (jest and babel)
    npm install

    Testing

    Tests are handled by jest. The following script will run tests continuously:

    npm test

    To see coverage:

    npx jest --coverage

    Playground

    Using "live server" functionality with an IDE, serve up test/dom-files/dom-sample.html. If no live server is available:

    ## Run a server and open a browser to the page
    npx http-server -o test/dom-files/dom-sample.html

    Bugs

    To report a defect or unexpected behavior, please visit the (GitHub issues page)[https://github.com/hal313/feature-switch-core/issues].

    Install

    npm i @hal313/feature-switch-core

    DownloadsWeekly Downloads

    2

    Version

    1.0.3

    License

    MIT

    Unpacked Size

    163 kB

    Total Files

    33

    Last publish

    Collaborators

    • hal313