splitio / javascript-browser-client

Other
4 stars 0 forks source link

Using features property with consumer mode produces a type error #108

Open dereksdev opened 10 months ago

dereksdev commented 10 months ago

Using the features property in "consumer" or "consumer_partial" mode produces the following type error.

No overload matches this call.
  Overload 2 of 2, '(settings: IBrowserAsyncSettings): IAsyncSDK', gave the following error.
    Object literal may only specify known properties, and 'features' does not exist in type 'IBrowserAsyncSettings'.ts(2769)

Example:

import {
  LocalhostFromObject,
  PluggableStorage,
  SplitFactory,
} from "@splitsoftware/splitio-browserjs";
import { EdgeConfigWrapper } from "@splitsoftware/vercel-integration-utils";
import { createClient } from "@vercel/edge-config";

function factory(): SplitIO.IAsyncClient {
  return SplitFactory({
    core: {
      authorizationKey: "localhost",
      key: "exampleUserKey",
    },
    mode: "consumer",
    storage: PluggableStorage({
      wrapper: EdgeConfigWrapper({
        edgeConfigItemKey: "exampleItemKey",
        edgeConfig: createClient("exampleEdgeConfig"),
      }),
    }),
    features: {}, // this line produces the type error
    sync: {
      localhostMode: LocalhostFromObject(),
    },
  }).client();
}
EmilianoSanchez commented 10 months ago

Hi @dereksdev ,

The features property is used for testing purposes as described here, and is only available for the default mode (a.k.a, standalone or InMemory mode).

So you cannot use it with modes consumer or consumer_partial, and it is enforced via the TypeScript interface IBrowserAsyncSettings.

In summary:

dereksdev commented 10 months ago

Thank you @EmilianoSanchez. This helps me understand the intent better.

The features property does actually still function with the mode set to consumer or consumer_partial which led to my confusion.

So in order to test in consumer mode, do we need to create a mock PluggableStorage object? Any guidance here would be appreciated.

EmilianoSanchez commented 10 months ago

Hi @dereksdev ,

The features property does actually still function with the mode set to consumer or consumer_partial which led to my confusion.

If you are using just JavaScript, yes, you can pass the features property, and it will not complain, but it will be ignored in both consumer modes.

So in order to test in consumer mode, do we need to create a mock PluggableStorage object? Any guidance here would be appreciated.

We are going to take a look to it during January. At the moment there is not official support for localhost mode in consumer modes, but I can share with you a code snippet you can try on your side, based on the implementation of a storage wrapper in memory.

However, keep in mind that this might not be the final solution for sure.

// index.js
import { SplitFactory, PluggableStorage } from '@splitsoftware/splitio-browserjs';
import { inMemoryWrapperFactory } from './inMemoryWrapper.js';

const inMemoryWrapper = inMemoryWrapperFactory({
  // `features` format as explained here: https://help.split.io/hc/en-us/articles/360058730852-Browser-SDK#localhost-mode
  features: {
    'feature_1': 'on',
  }
});

const factory = SplitFactory({
  core: {
    authorizationKey: 'anything-except-localhost',
    key: 'user_x'
  },
  mode: 'consumer',
  storage: PluggableStorage({
    wrapper: inMemoryWrapper
  })
});

const client = factory.client();

client.ready().then(async () => {
  console.log(await client.getTreatment('feature_1')); // on

  console.log(inMemoryWrapper._cache);
});
// inMemoryWrapper.js
/**
 * Creates a IPluggableStorageWrapper implementation that stores items in memory.
 * The `_cache` property is the object were items are stored.
 * Intended for testing purposes.
 */
export function inMemoryWrapperFactory({ features, prefix } = {}) {

  let _cache = {};

  const wrapper = {
    _cache,

    get(key) {
      return Promise.resolve(key in _cache ? _cache[key] : null);
    },
    set(key, value) {
      const result = key in _cache;
      _cache[key] = value;
      return Promise.resolve(result);
    },
    getAndSet(key, value) {
      const result = key in _cache ? _cache[key] : null;
      _cache[key] = value;
      return Promise.resolve(result);
    },
    del(key) {
      const result = key in _cache;
      delete _cache[key];
      return Promise.resolve(result);
    },
    getKeysByPrefix(prefix) {
      return Promise.resolve(Object.keys(_cache).filter(key => key.startsWith(prefix)));
    },
    incr(key, increment = 1) {
      if (key in _cache) {
        const count = parseInt(_cache[key]) + increment;
        if (isNaN(count)) return Promise.reject('Given key is not a number');
        _cache[key] = count + '';
        return Promise.resolve(count);
      } else {
        _cache[key] = '' + increment;
        return Promise.resolve(1);
      }
    },
    decr(key, decrement = 1) {
      if (key in _cache) {
        const count = parseInt(_cache[key]) - decrement;
        if (isNaN(count)) return Promise.reject('Given key is not a number');
        _cache[key] = count + '';
        return Promise.resolve(count);
      } else {
        _cache[key] = '-' + decrement;
        return Promise.resolve(-1);
      }
    },
    getMany(keys) {
      return Promise.resolve(keys.map(key => _cache[key] ? _cache[key] : null));
    },
    pushItems(key, items) {
      if (!(key in _cache)) _cache[key] = [];
      const list = _cache[key];
      if (Array.isArray(list)) {
        list.push(...items);
        return Promise.resolve();
      }
      return Promise.reject('key is not a list');
    },
    popItems(key, count) {
      const list = _cache[key];
      return Promise.resolve(Array.isArray(list) ? list.splice(0, count) : []);
    },
    getItemsCount(key) {
      const list = _cache[key];
      return Promise.resolve(Array.isArray(list) ? list.length : 0);
    },
    itemContains(key, item) {
      const set = _cache[key];
      if (!set) return Promise.resolve(false);
      if (set instanceof Set) return Promise.resolve(set.has(item));
      return Promise.reject('key is not a set');
    },
    addItems(key, items) {
      if (!(key in _cache)) _cache[key] = new Set();
      const set = _cache[key];
      if (set instanceof Set) {
        items.forEach(item => set.add(item));
        return Promise.resolve();
      }
      return Promise.reject('key is not a set');
    },
    removeItems(key, items) {
      if (!(key in _cache)) _cache[key] = new Set();
      const set = _cache[key];
      if (set instanceof Set) {
        items.forEach(item => set.delete(item));
        return Promise.resolve();
      }
      return Promise.reject('key is not a set');
    },
    getItems(key) {
      const set = _cache[key];
      if (!set) return Promise.resolve([]);
      if (set instanceof Set) return Promise.resolve(setToArray(set));
      return Promise.reject('key is not a set');
    },

    // always connects and disconnects
    connect() { return Promise.resolve(); },
    disconnect() { return Promise.resolve(); },
  };

  // Add features to wrapper
  if (features) {
    function parseCondition(data) {
      const treatment = data.treatment;

      if (data.keys) {
        return {
          conditionType: 'WHITELIST',
          matcherGroup: {
            combiner: 'AND',
            matchers: [
              {
                keySelector: null,
                matcherType: 'WHITELIST',
                negate: false,
                whitelistMatcherData: {
                  whitelist: typeof data.keys === 'string' ? [data.keys] : data.keys
                }
              }
            ]
          },
          partitions: [
            {
              treatment: treatment,
              size: 100
            }
          ],
          label: `whitelisted ${treatment}`
        };
      } else {
        return {
          conditionType: 'ROLLOUT',
          matcherGroup: {
            combiner: 'AND',
            matchers: [
              {
                keySelector: null,
                matcherType: 'ALL_KEYS',
                negate: false
              }
            ]
          },
          partitions: [
            {
              treatment: treatment,
              size: 100
            }
          ],
          label: 'default rule'
        };
      }
    }

    function splitsParserFromFeatures(features) {
      const splitObjects = {};

      Object.entries(features).forEach(([splitName, data]) => {
        let treatment = data;
        let config = null;

        if (data !== null && typeof data === 'object') {
          treatment = data.treatment;
          config = data.config || config;
        }
        const configurations = {};
        if (config !== null) configurations[treatment] = config;

        splitObjects[splitName] = {
          name: splitName,
          trafficTypeName: 'localhost',
          conditions: [parseCondition({ treatment: treatment })],
          configurations,
          status: 'ACTIVE',
          killed: false,
          trafficAllocation: 100,
          defaultTreatment: 'control',
        };
      });

      return splitObjects;
    };

    const featureFlags = splitsParserFromFeatures(features);
    prefix = prefix ? prefix + '.SPLITIO' : 'SPLITIO';

    Object.entries(featureFlags).forEach(([featureFlagName, featureFlag]) => {
      const featureFlagKey = `${prefix}.split.${featureFlagName}`;
      wrapper.set(featureFlagKey, JSON.stringify(featureFlag))
    });
  }

  return wrapper;
}