├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── .prettierrc.js ├── LICENSE ├── README.md ├── azure-pipelines.yml ├── demo ├── assets │ └── css │ │ ├── bootstrap.min.511.css │ │ └── bootstrap.min.js ├── demo2.html ├── demo3.html └── index.html ├── memo.txt ├── package-lock.json ├── package.json ├── src ├── autocapture.ts ├── constants.ts ├── devmode.ts ├── events.ts ├── ffc.ts ├── index.ts ├── logger.ts ├── network.service.ts ├── queue.ts ├── store.ts ├── throttleutil.ts ├── types.ts ├── umd.ts └── utils.ts ├── tsconfig.json ├── tslint.json └── webpack.config.js /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/gts/", 3 | "prettier/prettier": [ 4 | "error", 5 | { 6 | "endOfLine": "auto" 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | umd 3 | esm 4 | commonjs 5 | .idea 6 | tests 7 | .vscode 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | **/tsconfig.json 3 | **/webpack.config.js 4 | node_modules 5 | src -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "endOfLine":"auto", 3 | ...require('gts/.prettierrc.json') 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JavaScript client side SDK 2 | 3 | 4 | ## Introduction 5 | This is the JavaScript client side SDK for the feature management platform [feature-flags.co](https://www.featureflag.co). We will document all the methods available in this SDK, and detail how they work. 6 | 7 | Be aware, this is a client side SDK, it is intended for use in a single-user context, which can be mobile, desktop or embeded applications. This SDK can only be ran in a browser environment, it is not suitable for NodeJs applications, server side SDKs are available in our other repos. 8 | 9 | This SDK has two main works: 10 | - Makes feature flags avaible to the client side code 11 | - Sends feature flags usage, click, pageview and custom events for the insights and A/B/n testing. 12 | 13 | ## Data synchonization 14 | We use websocket to make the local data synchronized with the server, and then persist in localStorage. Whenever there is any changes to a feature flag, the changes would be pushed to the SDK, the average synchronization time is less than **100** ms. Be aware the websocket connection can be interrupted by any error or internet interruption, but it would be restored automatically right after the problem is gone. 15 | 16 | ## Offline mode support 17 | As all data is stored locally in the localStorage, in the following situations, the SDK would still work when there is temporarily no internet connection: 18 | - it has already recieved the data from previous conections 19 | - the Ffc.bootstrap(featureFlags) method is called with all necessary feature flags 20 | 21 | In the mean time, the SDK would try to reconnect to the server by an incremental interval, this makes sure that the websocket would be restored when the internet connection is back. 22 | 23 | ## Evaluation of a feature flag 24 | After initialization, the SDK has all the feature flags locally and it does not need to request the remote server for any feature flag evaluation. All evaluation is done locally and synchronously, the average evaluation time is about **1** ms. 25 | 26 | ## Getting started 27 | ### Install 28 | npm 29 | ``` 30 | npm install ffc-js-client-side-sdk 31 | ``` 32 | 33 | yarn 34 | ``` 35 | yarn add ffc-js-client-side-sdk 36 | ``` 37 | 38 | 44 | 45 | To import the SDK: 46 | ```javascript 47 | // Using ES2015 imports 48 | import Ffc from 'ffc-js-client-side-sdk'; 49 | 50 | // Using TypeScript imports 51 | import Ffc from 'ffc-js-client-side-sdk'; 52 | 53 | // Using react imports 54 | import Ffc from 'ffc-js-client-side-sdk'; 55 | ``` 56 | 57 | If using typescipt and seeing the following error: 58 | ``` 59 | Cannot find module 'ffc-js-client-sdk/esm'. Did you mean to set the 'moduleResolution' option to 'node', or to add aliases to the 'paths' option? 60 | ``` 61 | just add this in your tsconfig.json file 62 | ```json 63 | "compilerOptions": { 64 | "moduleResolution": "node" 65 | }, 66 | ``` 67 | 68 | ### Initializing the SDK 69 | Before initializing the SDK, you need to get the client-side env secret of your environment from our SaaS platform. 70 | 71 | ```javascript 72 | const option = { 73 | secret: 'your env secret', 74 | user: { 75 | userName: 'the user's user name', 76 | id: 'the user's unique identifier' 77 | } 78 | }; 79 | 80 | Ffc.init(option); 81 | ``` 82 | 83 | The complete list of the available parameters in option: 84 | - **secret**: the client side secret of your environment. **mandatory** (NB. this becomes optional if enableDataSync equals false) 85 | - **anonymous**: true if you want to use a anonymous user, which is the case before user login to your APP. If that is your case, the user can be set later with the **identify** method after the user has logged in. The default value is false. **not mandatory** 86 | - **bootstrap**: init the SDK with feature flags, this will trigger the ready event immediately instead of requesting from the remote. **not mandatory** 87 | - **enableDataSync**: false if you do not want to sync data with remote server, in this case feature flags must be set to **bootstrap** option or be passed to the method **bootstrap**. The default value is true. **not mandatory** 88 | - **devModePassword**: if set, the developer mode is enabled and it must be activated by calling the method **activateDevMode** with password on Ffc . **not mandatory** 89 | - **api**: the API url of the server, set it only if you are self hosting the back-end. **not mandatory** 90 | - **appType**: the app type, the default value is javascript, **not mandatory** 91 | - **user**: the user connected to your APP, can be ignored if **anonymous** equals to true. 92 | - **userName**: the user name. **mandatory** 93 | - **id**: the unique identifier. **mandatory** 94 | - **email**: can be useful when you configure your feature flag rules. **not mandatory** 95 | - **country**: can be useful when you configure your feature flag rules. **not mandatory** 96 | - **customizedProperties**: any customized properties you want to send to the back end. It is extremely powerful when you define targeting rules or segments. **not mandatory** 97 | - it must have the following format: 98 | ```json 99 | [{ 100 | "name": "the name of the property", 101 | "value": "the value of the property" 102 | }] 103 | ``` 104 | 105 | #### Initialization delay 106 | Initializing the client makes a remote request to featureflag.co, so it may take 100 milliseconds or more before the SDK emits the ready event. If you require feature flag values before rendering the page, we recommend bootstrapping the client. If you bootstrap the client, it will emit the ready event immediately. 107 | 108 | ### Get the varation value of a feature flag 109 | Two methods to get the variation of a feature flag 110 | 111 | ```javascript 112 | // Use this method for all cases 113 | // This method supports type inspection, it returns the value with the type defined on remote, 114 | // so defaultValue should have the same type as defined on remote 115 | var flagValue = Ffc.variation("YOUR_FEATURE_KEY", defaultValue); 116 | ``` 117 | 118 | ### Developer mode 119 | Developer mode is a powerful tool we created allowing developers to manipulate the feature flags locally instead of modifying them on [feature-flags.co](feature-flags.co). **This will not change the remote values**. 120 | 121 | To activate the developer mode, the activateDevMode method should be called as following, the password parameter is 122 | ```javascript 123 | // This will activate developer mode, you should be able to see an icon on bottom right of the screen. 124 | // PASSWORD is mandatory and it should be the same as the value passed to option 125 | Ffc.activateDevMode('PASSWORD'); 126 | 127 | // or 128 | // this method is equivalent to Ffc.activateDevMode('PASSWORD') 129 | window.activateFfcDevMode('PASSWORD'); 130 | ``` 131 | 132 | To open the developer mode editor or quit developer mode, use the following code: 133 | ```javascript 134 | // The method will open the developer mode editor, or you can just click on the developer mode icon 135 | Ffc.openDevModeEditor(); 136 | 137 | // call this method to quit developer mode 138 | Ffc.quitDevMode(); 139 | 140 | // or 141 | // this is equivalent to Ffc.quitDevMode() 142 | window.quitFfcDevMode(); 143 | ``` 144 | 145 | ### bootstrap 146 | If you already have the feature flags available, two ways to pass them to the SDK instead of requesting from the remote. 147 | - By the **init** method 148 | ```javascript 149 | // define the option with the bootstrap parameter 150 | const option = { 151 | ... 152 | // the array should contain all your feature flags 153 | bootstrap = [{ 154 | // feature flag key name 155 | id: string, 156 | // variation value 157 | variation: string, 158 | // variation data type, string will used if not specified 159 | variationType: string 160 | }], 161 | ... 162 | } 163 | 164 | Ffc.init(option); 165 | ``` 166 | 167 | - By the **bootstrap** method 168 | ```javascript 169 | // this array should contain all your feature flags 170 | const featureflags = [{ 171 | // feature flag key name 172 | id: string, 173 | // variation value 174 | variation: string, 175 | // variation data type, string will used if not specified 176 | variationType: string 177 | }] 178 | 179 | Ffc.bootstrap(featureflags); 180 | ``` 181 | 182 | **If you want to disable the synchronization with remote server, set enableDataSync to false in option**. In this case, bootstrap option must be set or bootstrap method must be called with feature flags. 183 | 184 | To find out when the client is ready, you can use one of two mechanisms: events or promises. 185 | 186 | The client object can emit JavaScript events. It emits a ready event when it receives initial flag values from feature-flags.co. You can listen for this event to determine when the client is ready to evaluate flags. 187 | 188 | ```javascript 189 | Ffc.on('ready', (data) => { 190 | // data has the following structure [ {id: 'featureFlagKey', variation: variationValue} ] 191 | // variationValue has the type as defined on remote 192 | var flagValue = Ffc.variation("YOUR_FEATURE_KEY", defaultValue); 193 | }); 194 | 195 | ``` 196 | 197 | Or, you can use a promise instead of an event. The SDK has a method that return a promise for initialization: waitUntilReady(). The behavior of waitUntilReady() is equivalent to the ready event. The promise resolves when the client receives its initial flag data. As with all promises, you can either use .then() to provide a callback, or use await if you are writing asynchronous code. 198 | 199 | ```javascript 200 | Ffc.waitUntilReady().then((data) => { 201 | // data has the following structure [ {id: 'featureFlagKey', variation: variationValue } ] 202 | // variationValue has the type as defined on remote 203 | // initialization succeeded, flag values are now available 204 | }); 205 | // or, with await: 206 | const featureFlags = await Ffc.waitUntilReady(); 207 | // initialization succeeded, flag values are now available 208 | ``` 209 | 210 | The SDK only decides initialization has failed if it receives an error response indicating that the environment ID is invalid. If it has trouble connecting to feature-flags.co, it will keep retrying until it succeeds. 211 | 212 | ### Set the user after initialization 213 | If the user parameter cannot be passed by the init method, the following method can be used to set the user after initialization. 214 | ```javascript 215 | Ffc.identify(user); 216 | ``` 217 | 218 | ### Set the user to anonymous user 219 | We can manully call the method logout, which will switch the current user back to anonymous user if exists already or create a new anonymous user. 220 | ```javascript 221 | Ffc.logout(user); 222 | ``` 223 | 224 | ### Subscribe to the changes of feature flag(s) 225 | To get notified when a feature flag is changed, we offer two methods 226 | - subscribe to the changes of any feature flag(s) 227 | ```javascript 228 | Ffc.on('ff_update', (changes) => { 229 | // changes has this structure [{id: 'the feature_flag_key', oldValue: theOldValue, newValue: theNewValue }] 230 | // theOldValue and theNewValue have the type as defined on remote 231 | ... 232 | }); 233 | 234 | ``` 235 | - subscribe to the changes of a specific feature flag 236 | ```javascript 237 | // replace feature_flag_key with your feature flag key 238 | Ffc.on('ff_update:feature_flag_key', (change) => { 239 | // change has this structure {id: 'the feature_flag_key', oldValue: theOldValue, newValue: theNewValue } 240 | // theOldValue and theNewValue have the type as defined on remote 241 | 242 | // defaultValue should have the type as defined on remote 243 | const myFeature = Ffc.variation('feature_flag_key', defaultValue); 244 | ... 245 | }); 246 | 247 | ``` 248 | 249 | ## Experiments (A/B/n Testing) 250 | We support automatic experiments for pageviews and clicks, you just need to set your experiment on our SaaS platform, then you should be able to see the result in near real time after the experiment is started. 251 | 252 | In case you need more control over the experiment data sent to our server, we offer a method to send custom event. 253 | ```javascript 254 | Ffc.sendCustomEvent([{ 255 | eventName: 'your event name', 256 | numericValue: 1 257 | }]) 258 | ``` 259 | **numericValue** is not mandatory, the default value is **1**. 260 | 261 | Make sure sendCustomEvent is called after the related feature flag is called by simply calling **Ffc.variation('featureFlagKeyName', 'default value')**, otherwise, the custom event won't be included into the experiment result. 262 | 263 | 264 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Starter pipeline 2 | # Start with a minimal pipeline that you can customize to build and deploy your code. 3 | # Add steps that build, run tests, deploy, and more: 4 | # https://aka.ms/yaml 5 | 6 | trigger: none 7 | 8 | pool: 9 | vmImage: windows-latest 10 | 11 | 12 | steps: 13 | - checkout: self 14 | - task: NodeTool@0 15 | inputs: 16 | versionSpec: '14.x' 17 | displayName: 'Install Node.js' 18 | 19 | - task: Bash@3 20 | inputs: 21 | targetType: 'inline' 22 | script: | 23 | cd '$(System.DefaultWorkingDirectory)' 24 | 25 | npm install 26 | npm run build 27 | displayName: 'npm install and build' 28 | 29 | - task: AzureFileCopy@4 30 | inputs: 31 | SourcePath: $(System.DefaultWorkingDirectory)/umd/* 32 | azureSubscription: 'azure-china-devops' 33 | Destination: 'AzureBlob' 34 | storage: 'ffc0st0media0ce2' 35 | ContainerName: 'sdks' 36 | -------------------------------------------------------------------------------- /demo/assets/css/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v4.4.1 (https://getbootstrap.com/) 3 | * Copyright 2011-2019 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("jquery"),require("popper.js")):"function"==typeof define&&define.amd?define(["exports","jquery","popper.js"],e):e((t=t||self).bootstrap={},t.jQuery,t.Popper)}(this,function(t,g,u){"use strict";function i(t,e){for(var n=0;nthis._items.length-1||t<0))if(this._isSliding)g(this._element).one(Y.SLID,function(){return e.to(t)});else{if(n===t)return this.pause(),void this.cycle();var i=ndocument.documentElement.clientHeight;!this._isBodyOverflowing&&t&&(this._element.style.paddingLeft=this._scrollbarWidth+"px"),this._isBodyOverflowing&&!t&&(this._element.style.paddingRight=this._scrollbarWidth+"px")},t._resetAdjustments=function(){this._element.style.paddingLeft="",this._element.style.paddingRight=""},t._checkScrollbar=function(){var t=document.body.getBoundingClientRect();this._isBodyOverflowing=t.left+t.right
',trigger:"hover focus",title:"",delay:0,html:!1,selector:!1,placement:"top",offset:0,container:!1,fallbackPlacement:"flip",boundary:"scrollParent",sanitize:!0,sanitizeFn:null,whiteList:Se,popperConfig:null},Fe="show",Ue="out",We={HIDE:"hide"+Oe,HIDDEN:"hidden"+Oe,SHOW:"show"+Oe,SHOWN:"shown"+Oe,INSERTED:"inserted"+Oe,CLICK:"click"+Oe,FOCUSIN:"focusin"+Oe,FOCUSOUT:"focusout"+Oe,MOUSEENTER:"mouseenter"+Oe,MOUSELEAVE:"mouseleave"+Oe},qe="fade",Me="show",Ke=".tooltip-inner",Qe=".arrow",Be="hover",Ve="focus",Ye="click",ze="manual",Xe=function(){function i(t,e){if("undefined"==typeof u)throw new TypeError("Bootstrap's tooltips require Popper.js (https://popper.js.org/)");this._isEnabled=!0,this._timeout=0,this._hoverState="",this._activeTrigger={},this._popper=null,this.element=t,this.config=this._getConfig(e),this.tip=null,this._setListeners()}var t=i.prototype;return t.enable=function(){this._isEnabled=!0},t.disable=function(){this._isEnabled=!1},t.toggleEnabled=function(){this._isEnabled=!this._isEnabled},t.toggle=function(t){if(this._isEnabled)if(t){var e=this.constructor.DATA_KEY,n=g(t.currentTarget).data(e);n||(n=new this.constructor(t.currentTarget,this._getDelegateConfig()),g(t.currentTarget).data(e,n)),n._activeTrigger.click=!n._activeTrigger.click,n._isWithActiveTrigger()?n._enter(null,n):n._leave(null,n)}else{if(g(this.getTipElement()).hasClass(Me))return void this._leave(null,this);this._enter(null,this)}},t.dispose=function(){clearTimeout(this._timeout),g.removeData(this.element,this.constructor.DATA_KEY),g(this.element).off(this.constructor.EVENT_KEY),g(this.element).closest(".modal").off("hide.bs.modal",this._hideModalHandler),this.tip&&g(this.tip).remove(),this._isEnabled=null,this._timeout=null,this._hoverState=null,this._activeTrigger=null,this._popper&&this._popper.destroy(),this._popper=null,this.element=null,this.config=null,this.tip=null},t.show=function(){var e=this;if("none"===g(this.element).css("display"))throw new Error("Please use show on visible elements");var t=g.Event(this.constructor.Event.SHOW);if(this.isWithContent()&&this._isEnabled){g(this.element).trigger(t);var n=_.findShadowRoot(this.element),i=g.contains(null!==n?n:this.element.ownerDocument.documentElement,this.element);if(t.isDefaultPrevented()||!i)return;var o=this.getTipElement(),r=_.getUID(this.constructor.NAME);o.setAttribute("id",r),this.element.setAttribute("aria-describedby",r),this.setContent(),this.config.animation&&g(o).addClass(qe);var s="function"==typeof this.config.placement?this.config.placement.call(this,o,this.element):this.config.placement,a=this._getAttachment(s);this.addAttachmentClass(a);var l=this._getContainer();g(o).data(this.constructor.DATA_KEY,this),g.contains(this.element.ownerDocument.documentElement,this.tip)||g(o).appendTo(l),g(this.element).trigger(this.constructor.Event.INSERTED),this._popper=new u(this.element,o,this._getPopperConfig(a)),g(o).addClass(Me),"ontouchstart"in document.documentElement&&g(document.body).children().on("mouseover",null,g.noop);var c=function(){e.config.animation&&e._fixTransition();var t=e._hoverState;e._hoverState=null,g(e.element).trigger(e.constructor.Event.SHOWN),t===Ue&&e._leave(null,e)};if(g(this.tip).hasClass(qe)){var h=_.getTransitionDurationFromElement(this.tip);g(this.tip).one(_.TRANSITION_END,c).emulateTransitionEnd(h)}else c()}},t.hide=function(t){function e(){n._hoverState!==Fe&&i.parentNode&&i.parentNode.removeChild(i),n._cleanTipClass(),n.element.removeAttribute("aria-describedby"),g(n.element).trigger(n.constructor.Event.HIDDEN),null!==n._popper&&n._popper.destroy(),t&&t()}var n=this,i=this.getTipElement(),o=g.Event(this.constructor.Event.HIDE);if(g(this.element).trigger(o),!o.isDefaultPrevented()){if(g(i).removeClass(Me),"ontouchstart"in document.documentElement&&g(document.body).children().off("mouseover",null,g.noop),this._activeTrigger[Ye]=!1,this._activeTrigger[Ve]=!1,this._activeTrigger[Be]=!1,g(this.tip).hasClass(qe)){var r=_.getTransitionDurationFromElement(i);g(i).one(_.TRANSITION_END,e).emulateTransitionEnd(r)}else e();this._hoverState=""}},t.update=function(){null!==this._popper&&this._popper.scheduleUpdate()},t.isWithContent=function(){return Boolean(this.getTitle())},t.addAttachmentClass=function(t){g(this.getTipElement()).addClass(Pe+"-"+t)},t.getTipElement=function(){return this.tip=this.tip||g(this.config.template)[0],this.tip},t.setContent=function(){var t=this.getTipElement();this.setElementContent(g(t.querySelectorAll(Ke)),this.getTitle()),g(t).removeClass(qe+" "+Me)},t.setElementContent=function(t,e){"object"!=typeof e||!e.nodeType&&!e.jquery?this.config.html?(this.config.sanitize&&(e=we(e,this.config.whiteList,this.config.sanitizeFn)),t.html(e)):t.text(e):this.config.html?g(e).parent().is(t)||t.empty().append(e):t.text(g(e).text())},t.getTitle=function(){var t=this.element.getAttribute("data-original-title");return t=t||("function"==typeof this.config.title?this.config.title.call(this.element):this.config.title)},t._getPopperConfig=function(t){var e=this;return l({},{placement:t,modifiers:{offset:this._getOffset(),flip:{behavior:this.config.fallbackPlacement},arrow:{element:Qe},preventOverflow:{boundariesElement:this.config.boundary}},onCreate:function(t){t.originalPlacement!==t.placement&&e._handlePopperPlacementChange(t)},onUpdate:function(t){return e._handlePopperPlacementChange(t)}},{},this.config.popperConfig)},t._getOffset=function(){var e=this,t={};return"function"==typeof this.config.offset?t.fn=function(t){return t.offsets=l({},t.offsets,{},e.config.offset(t.offsets,e.element)||{}),t}:t.offset=this.config.offset,t},t._getContainer=function(){return!1===this.config.container?document.body:_.isElement(this.config.container)?g(this.config.container):g(document).find(this.config.container)},t._getAttachment=function(t){return Re[t.toUpperCase()]},t._setListeners=function(){var i=this;this.config.trigger.split(" ").forEach(function(t){if("click"===t)g(i.element).on(i.constructor.Event.CLICK,i.config.selector,function(t){return i.toggle(t)});else if(t!==ze){var e=t===Be?i.constructor.Event.MOUSEENTER:i.constructor.Event.FOCUSIN,n=t===Be?i.constructor.Event.MOUSELEAVE:i.constructor.Event.FOCUSOUT;g(i.element).on(e,i.config.selector,function(t){return i._enter(t)}).on(n,i.config.selector,function(t){return i._leave(t)})}}),this._hideModalHandler=function(){i.element&&i.hide()},g(this.element).closest(".modal").on("hide.bs.modal",this._hideModalHandler),this.config.selector?this.config=l({},this.config,{trigger:"manual",selector:""}):this._fixTitle()},t._fixTitle=function(){var t=typeof this.element.getAttribute("data-original-title");!this.element.getAttribute("title")&&"string"==t||(this.element.setAttribute("data-original-title",this.element.getAttribute("title")||""),this.element.setAttribute("title",""))},t._enter=function(t,e){var n=this.constructor.DATA_KEY;(e=e||g(t.currentTarget).data(n))||(e=new this.constructor(t.currentTarget,this._getDelegateConfig()),g(t.currentTarget).data(n,e)),t&&(e._activeTrigger["focusin"===t.type?Ve:Be]=!0),g(e.getTipElement()).hasClass(Me)||e._hoverState===Fe?e._hoverState=Fe:(clearTimeout(e._timeout),e._hoverState=Fe,e.config.delay&&e.config.delay.show?e._timeout=setTimeout(function(){e._hoverState===Fe&&e.show()},e.config.delay.show):e.show())},t._leave=function(t,e){var n=this.constructor.DATA_KEY;(e=e||g(t.currentTarget).data(n))||(e=new this.constructor(t.currentTarget,this._getDelegateConfig()),g(t.currentTarget).data(n,e)),t&&(e._activeTrigger["focusout"===t.type?Ve:Be]=!1),e._isWithActiveTrigger()||(clearTimeout(e._timeout),e._hoverState=Ue,e.config.delay&&e.config.delay.hide?e._timeout=setTimeout(function(){e._hoverState===Ue&&e.hide()},e.config.delay.hide):e.hide())},t._isWithActiveTrigger=function(){for(var t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1},t._getConfig=function(t){var e=g(this.element).data();return Object.keys(e).forEach(function(t){-1!==je.indexOf(t)&&delete e[t]}),"number"==typeof(t=l({},this.constructor.Default,{},e,{},"object"==typeof t&&t?t:{})).delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),_.typeCheckConfig(Ae,t,this.constructor.DefaultType),t.sanitize&&(t.template=we(t.template,t.whiteList,t.sanitizeFn)),t},t._getDelegateConfig=function(){var t={};if(this.config)for(var e in this.config)this.constructor.Default[e]!==this.config[e]&&(t[e]=this.config[e]);return t},t._cleanTipClass=function(){var t=g(this.getTipElement()),e=t.attr("class").match(Le);null!==e&&e.length&&t.removeClass(e.join(""))},t._handlePopperPlacementChange=function(t){var e=t.instance;this.tip=e.popper,this._cleanTipClass(),this.addAttachmentClass(this._getAttachment(t.placement))},t._fixTransition=function(){var t=this.getTipElement(),e=this.config.animation;null===t.getAttribute("x-placement")&&(g(t).removeClass(qe),this.config.animation=!1,this.hide(),this.show(),this.config.animation=e)},i._jQueryInterface=function(n){return this.each(function(){var t=g(this).data(Ne),e="object"==typeof n&&n;if((t||!/dispose|hide/.test(n))&&(t||(t=new i(this,e),g(this).data(Ne,t)),"string"==typeof n)){if("undefined"==typeof t[n])throw new TypeError('No method named "'+n+'"');t[n]()}})},s(i,null,[{key:"VERSION",get:function(){return"4.4.1"}},{key:"Default",get:function(){return xe}},{key:"NAME",get:function(){return Ae}},{key:"DATA_KEY",get:function(){return Ne}},{key:"Event",get:function(){return We}},{key:"EVENT_KEY",get:function(){return Oe}},{key:"DefaultType",get:function(){return He}}]),i}();g.fn[Ae]=Xe._jQueryInterface,g.fn[Ae].Constructor=Xe,g.fn[Ae].noConflict=function(){return g.fn[Ae]=ke,Xe._jQueryInterface};var $e="popover",Ge="bs.popover",Je="."+Ge,Ze=g.fn[$e],tn="bs-popover",en=new RegExp("(^|\\s)"+tn+"\\S+","g"),nn=l({},Xe.Default,{placement:"right",trigger:"click",content:"",template:''}),on=l({},Xe.DefaultType,{content:"(string|element|function)"}),rn="fade",sn="show",an=".popover-header",ln=".popover-body",cn={HIDE:"hide"+Je,HIDDEN:"hidden"+Je,SHOW:"show"+Je,SHOWN:"shown"+Je,INSERTED:"inserted"+Je,CLICK:"click"+Je,FOCUSIN:"focusin"+Je,FOCUSOUT:"focusout"+Je,MOUSEENTER:"mouseenter"+Je,MOUSELEAVE:"mouseleave"+Je},hn=function(t){function i(){return t.apply(this,arguments)||this}!function(t,e){t.prototype=Object.create(e.prototype),(t.prototype.constructor=t).__proto__=e}(i,t);var e=i.prototype;return e.isWithContent=function(){return this.getTitle()||this._getContent()},e.addAttachmentClass=function(t){g(this.getTipElement()).addClass(tn+"-"+t)},e.getTipElement=function(){return this.tip=this.tip||g(this.config.template)[0],this.tip},e.setContent=function(){var t=g(this.getTipElement());this.setElementContent(t.find(an),this.getTitle());var e=this._getContent();"function"==typeof e&&(e=e.call(this.element)),this.setElementContent(t.find(ln),e),t.removeClass(rn+" "+sn)},e._getContent=function(){return this.element.getAttribute("data-content")||this.config.content},e._cleanTipClass=function(){var t=g(this.getTipElement()),e=t.attr("class").match(en);null!==e&&0=this._offsets[o]&&("undefined"==typeof this._offsets[o+1]||t 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | feature-flags.co 13 | 14 | 15 | 16 |
欢迎新同学 Onboarding
17 |
欢迎新同学b Onboardingb
18 |
隐藏我
19 |
activate devmode
20 |
display dev mode editor
21 |
quit devmode
22 |
COLOR
23 | 41 | 42 | 151 | 152 | 153 | -------------------------------------------------------------------------------- /demo/demo3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | draggable attribute 5 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 |
26 | 27 | 72 | 73 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | feature-flags.co 13 | 14 | 15 | 16 | aagagar 17 |
18 |
19 |

产品经理版1

20 | 开始使用 21 |
22 |
23 |
24 |
25 |

程序员版1

` 26 | 开始使用 27 |
28 |
29 |
30 |
31 |

产品经理版2

32 | 开始使用 33 |
34 |
35 | 36 |
欢迎新同学 Onboarding
37 |
欢迎新同学b Onboardingb
38 |
隐藏我
39 |
activate devmode
40 |
display dev mode editor
41 |
quit devmode
42 |
COLOR
43 | 61 | 62 | 206 | 207 | 208 | -------------------------------------------------------------------------------- /memo.txt: -------------------------------------------------------------------------------- 1 | 1. create typescript project: https://www.digitalocean.com/community/tutorials/typescript-new-project 2 | 3 | _bundles/ // UMD bundles 4 | lib/ // ES5(commonjs) + source + .d.ts 5 | lib-esm/ // ES5(esmodule) + source + .d.ts 6 | 7 | 2. publish 8 | 2.1 npm login 9 | 2.2 npm publish --access public 10 | 11 | 3. Ref 12 | pageview: https://dev.to/zigabrencic/analytics-with-vanilla-js-page-views-47pb 13 | 14 | 4. Ant Design Pro 15 | 13552586056 密碼 任意 16 | 17 | dev 18 | http://localhost:8000/user/login?envkey=YjRlLWY1YjEtNCUyMDIxMDYwNzA2NTYwOF9fMl9fM19fN19fZGVmYXVsdF84NDNlMw== 19 | 20 | demo 21 | http://localhost:8000/user/login?envkey=OTNlLTYyM2UtNCUyMDIxMTAxOTA4NDYxMF9fMl9fM19fMjM0X19kZWZhdWx0XzRhMjE1 22 | 23 | for error: 'this' implicitly has type 'any' because it does not have a type annotation 24 | ref: https://www.valentinog.com/blog/this/ 25 | const button = document.querySelector("button"); 26 | button?.addEventListener("click", handleClick); 27 | 28 | function handleClick(this: HTMLElement) { 29 | console.log("Clicked!"); 30 | this.removeEventListener("click", handleClick); 31 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ffc-js-client-side-sdk", 3 | "version": "1.1.7", 4 | "description": "https://github.com/feature-flags-co/ffc-js-client-sdk", 5 | "main": "esm/ffc.js", 6 | "scripts": { 7 | "watch-esm": "rimraf esm && tsc --watch", 8 | "watch-umd": "rimraf umd && webpack --watch --mode development", 9 | "build": "rimraf umd && rimraf esm && tsc && webpack --mode production", 10 | "prepublishOnly": "npm run build", 11 | "lint": "gts lint", 12 | "clean": "gts clean", 13 | "compile": "tsc", 14 | "fix": "gts fix" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/feature-flags-co/ffc-js-client-sdk.git" 19 | }, 20 | "keywords": [ 21 | "feature-flags.co", 22 | "敏捷开关", 23 | "feature flags" 24 | ], 25 | "author": "北京心跳率科技有限公司", 26 | "license": "ISC", 27 | "bugs": { 28 | "url": "https://github.com/feature-flags-co/ffc-js-client-sdk/issues" 29 | }, 30 | "homepage": "https://github.com/feature-flags-co/ffc-js-client-sdk#readme", 31 | "files": [ 32 | "/esm", 33 | "/umd", 34 | "/src" 35 | ], 36 | "devDependencies": { 37 | "gts": "^3.1.0", 38 | "rimraf": "^3.0.2", 39 | "ts-loader": "^9.2.6", 40 | "tslint": "^6.1.3", 41 | "typescript": "^4.5.5", 42 | "webpack": "^5.67.0", 43 | "webpack-cli": "^4.9.2" 44 | }, 45 | "dependencies": { 46 | "string-replace-loader": "^3.1.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/autocapture.ts: -------------------------------------------------------------------------------- 1 | import Ffc from "./ffc"; 2 | import { eventHub } from "./events"; 3 | import store from "./store"; 4 | import { featureFlagEvaluatedTopic, insightsTopic } from "./constants"; 5 | import { networkService } from "./network.service"; 6 | import { EventType, FeatureFlagType, ICssSelectorItem, IExptMetricSetting, InsightType, IZeroCode, UrlMatchType } from "./types"; 7 | import { extractCSS, groupBy, isUrlMatch } from "./utils"; 8 | 9 | declare global { 10 | interface Window { 11 | WebKitMutationObserver:any; 12 | MozMutationObserver: any; 13 | } 14 | } 15 | 16 | const ffcSpecialValue = '___071218__'; 17 | 18 | class AutoCapture { 19 | 20 | constructor() {} 21 | 22 | async init() { 23 | const settings = await Promise.all([networkService.getActiveExperimentMetricSettings(), networkService.getZeroCodeSettings()]); 24 | 25 | await Promise.all([this.capturePageViews(settings[0]), this.trackZeroCodingAndClicks(settings[1], settings[0])]); 26 | const html = document.querySelector('html'); 27 | if (html) { 28 | html.style.visibility = 'visible'; 29 | } 30 | } 31 | 32 | private async capturePageViews(exptMetricSettings: IExptMetricSetting[]) { 33 | exptMetricSettings = exptMetricSettings || []; 34 | const self: AutoCapture = this; 35 | history.pushState = (f => function pushState(this: any) { 36 | const argumentsTyped: any = arguments; 37 | const ret = f.apply(this, argumentsTyped); 38 | window.dispatchEvent(new Event('pushstate')); 39 | window.dispatchEvent(new Event('locationchange')); 40 | return ret; 41 | })(history.pushState); 42 | 43 | history.replaceState = (f => function replaceState(this: any) { 44 | const argumentsTyped: any = arguments; 45 | const ret = f.apply(this, argumentsTyped); 46 | window.dispatchEvent(new Event('replacestate')); 47 | window.dispatchEvent(new Event('locationchange')); 48 | return ret; 49 | })(history.replaceState); 50 | 51 | window.addEventListener('popstate', () => { 52 | window.dispatchEvent(new Event('locationchange')) 53 | }); 54 | 55 | const pageViewSetting = exptMetricSettings 56 | .find(em => em.eventType === EventType.PageView && em.targetUrls.findIndex(t => isUrlMatch(t.matchType, t.url)) !== -1); 57 | 58 | if (!!pageViewSetting) { 59 | eventHub.emit(insightsTopic, { 60 | insightType: InsightType.pageView, 61 | type: 'PageView', 62 | route: window.location.href, 63 | eventName: pageViewSetting.eventName 64 | }); 65 | } 66 | 67 | window.addEventListener("locationchange", async function () { 68 | const pageViewSetting = exptMetricSettings 69 | .find(em => em.eventType === EventType.PageView && em.targetUrls.findIndex(t => isUrlMatch(t.matchType, t.url)) !== -1); 70 | 71 | if (!!pageViewSetting) { 72 | eventHub.emit(insightsTopic, { 73 | insightType: InsightType.pageView, 74 | type: 'PageView', 75 | route: window.location.href, 76 | eventName: pageViewSetting.eventName 77 | }); 78 | } 79 | }); 80 | } 81 | 82 | private async trackZeroCodingAndClicks(zeroCodeSettings: IZeroCode[], exptMetricSettings: IExptMetricSetting[]) { 83 | const self = this; 84 | var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;//浏览器兼容 85 | 86 | var callback = async function (mutationsList, observer) { 87 | if (mutationsList && mutationsList.length > 0) { 88 | observer.disconnect(); 89 | await Promise.all([self.bindClickHandlers(exptMetricSettings), self.zeroCodeSettingsCheckVariation(zeroCodeSettings, observer)]); 90 | observer.observe(document.body, { attributes: false, childList: true, subtree: true }); 91 | } 92 | }; 93 | 94 | const observer = new MutationObserver(callback); 95 | await Promise.all([this.bindClickHandlers(exptMetricSettings), this.zeroCodeSettingsCheckVariation(zeroCodeSettings, observer)]); 96 | observer.observe(document.body, { attributes: false, childList: true, subtree: true }); 97 | } 98 | 99 | private async bindClickHandlers(exptMetricSettings: IExptMetricSetting[]) { 100 | const clickHandler = (event) => { 101 | var target = event?.currentTarget as any; 102 | const data = [{ 103 | type: 'Click', 104 | route: window.location.href, 105 | eventName: target.dataffceventname 106 | }]; 107 | 108 | eventHub.emit(insightsTopic, { 109 | insightType: InsightType.click, 110 | type: 'Click', 111 | route: window.location.href, 112 | eventName: target.dataffceventname 113 | }); 114 | } 115 | 116 | const clickSetting = exptMetricSettings 117 | .find(em => em.eventType === EventType.Click && em.targetUrls.findIndex(t => isUrlMatch(t.matchType, t.url)) !== -1); 118 | 119 | if (!!clickSetting) { 120 | const nodes = document.querySelectorAll(clickSetting.elementTargets); 121 | nodes.forEach(node => { 122 | node['dataffceventname'] = clickSetting.eventName; 123 | node.removeEventListener('click', clickHandler); 124 | node.addEventListener('click', clickHandler); 125 | }); 126 | } 127 | } 128 | 129 | private async zeroCodeSettingsCheckVariation(zeroCodeSettings: IZeroCode[], observer: MutationObserver) { 130 | for(let zeroCodeSetting of zeroCodeSettings) { 131 | const effectiveItems = zeroCodeSetting.items?.filter(it => isUrlMatch(UrlMatchType.Substring, it.url)); 132 | 133 | if (zeroCodeSetting.featureFlagType === FeatureFlagType.Pretargeted) { 134 | // 客户已经做好用户分流 135 | for (let item of effectiveItems) { 136 | let node = document.querySelector(item.cssSelector) as HTMLElement; 137 | if (node !== null && node !== undefined) { 138 | // this send feature flag insights data 139 | const featureFlag = store.getFeatureFlag(zeroCodeSetting.featureFlagKey); 140 | if (!!featureFlag) { 141 | eventHub.emit(featureFlagEvaluatedTopic, { 142 | insightType: InsightType.featureFlagUsage, 143 | id: featureFlag.id, 144 | timestamp: Date.now(), 145 | sendToExperiment: featureFlag.sendToExperiment, 146 | variation: featureFlag.variationOptions.find(o => o.value === item.variationValue) 147 | }); 148 | } 149 | } 150 | } 151 | } else { 152 | if (!!effectiveItems && effectiveItems.length > 0) { 153 | const result = Ffc.variation(zeroCodeSetting.featureFlagKey, ffcSpecialValue); 154 | 155 | if (result !== ffcSpecialValue) { 156 | this.applyRules(effectiveItems, result); 157 | } 158 | 159 | Ffc.on(`ff_update:${zeroCodeSetting.featureFlagKey}`, () => { 160 | const result = Ffc.variation(zeroCodeSetting.featureFlagKey, ffcSpecialValue); 161 | if (result !== ffcSpecialValue) { 162 | this.applyRules(effectiveItems, result); 163 | } 164 | }); 165 | } else { 166 | if (zeroCodeSetting.items && zeroCodeSetting.items.length > 0) { 167 | this.revertRules(zeroCodeSetting.items); 168 | } 169 | } 170 | } 171 | } 172 | } 173 | 174 | private revertRules (items: ICssSelectorItem[]) { 175 | const cssSelectors = items.map(it => it.cssSelector).filter((v, i, a) => a.indexOf(v) === i).join(','); // the filter function returns unique values 176 | let nodes = document.querySelectorAll(cssSelectors) as NodeListOf; 177 | nodes.forEach(node => { 178 | const style = {}; 179 | if (node.style.display === 'none') { 180 | style['display'] = 'block'; 181 | } 182 | 183 | const rawStyle = node.getAttribute(`data-ffc-${ffcSpecialValue}`); 184 | if (rawStyle !== null && rawStyle !== '') { 185 | Object.assign(style, JSON.parse(rawStyle)); 186 | } 187 | 188 | Object.assign(node.style, style); 189 | }); 190 | } 191 | 192 | private applyRules(items: ICssSelectorItem[], ffResult: string) { 193 | const groupedItems: { [key: string]: ICssSelectorItem[] } = groupBy(items, 'variationValue'); 194 | 195 | // hide items 196 | for (let [variationValue, itms] of Object.entries(groupedItems)) { 197 | if (variationValue !== ffResult) { 198 | const cssSelectors = (itms as ICssSelectorItem[]).map(it => it.cssSelector).filter((v, i, a) => a.indexOf(v) === i).join(','); // the filter function returns unique values 199 | let nodes = document.querySelectorAll(cssSelectors) as NodeListOf; 200 | nodes.forEach(node => { 201 | const { position, left, top } = node.style; 202 | if (left !== '-99999px') { 203 | const style = { position, left, top }; 204 | node.setAttribute(`data-ffc-${ffcSpecialValue}`, JSON.stringify(style)); 205 | Object.assign(node.style, { position: 'absolute', left: '-99999px', top: '-99999px' }); 206 | } 207 | }); 208 | } 209 | } 210 | 211 | // show items (revert hiding) 212 | if (groupedItems[ffResult] && groupedItems[ffResult].length > 0) { 213 | this.showOrModifyElements(groupedItems[ffResult]); 214 | } 215 | } 216 | 217 | private showOrModifyElements(items: ICssSelectorItem[]) { 218 | items?.forEach(item => { 219 | let nodes = document.querySelectorAll(item.cssSelector) as NodeListOf; 220 | if (item.action === 'show' || item.action === 'modify') { 221 | nodes.forEach(node => { 222 | const style = {}; 223 | if (node.style.display === 'none') { 224 | style['display'] = 'block'; 225 | } 226 | 227 | const rawStyle = node.getAttribute(`data-ffc-${ffcSpecialValue}`); 228 | if (rawStyle !== null && rawStyle !== '') { 229 | Object.assign(style, JSON.parse(rawStyle)); 230 | } 231 | 232 | Object.assign(node.style, style); 233 | 234 | if (item.action === 'modify') { 235 | // apply properties 236 | item.htmlProperties?.forEach(p => { 237 | node.setAttribute(p.name, p.value); 238 | }); 239 | 240 | // apply content 241 | if (item.htmlContent) { 242 | node.innerHTML = item.htmlContent; 243 | } 244 | 245 | // apply style 246 | extractCSS(item.style).forEach(css => { 247 | node.style[css.name] = css.value; 248 | }) 249 | } 250 | }); 251 | } 252 | }); 253 | } 254 | } 255 | 256 | export default new AutoCapture(); 257 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const insightsFlushTopic = 'insights.flush'; 2 | export const featureFlagEvaluatedTopic = 'featureflag.evaluated.topic'; 3 | export const featureFlagEvaluatedBufferTopic = 'featureflag.evaluated.buffer.topic'; 4 | export const websocketReconnectTopic = 'network.websocket.reconnect'; 5 | export const debugModeQueryStr = 'debugmode'; // will print debug logs if true 6 | export const insightsTopic = 'insights.topic'; 7 | 8 | export const devModeEventName: string = 'ffcdevmodechange' 9 | export const devModeStorageKey = 'ffcdevmode'; 10 | export const devModeBtnId = 'ff-devmode-btn'; 11 | export const devModeQueryStr = 'devmode'; -------------------------------------------------------------------------------- /src/devmode.ts: -------------------------------------------------------------------------------- 1 | import { devModeBtnId, devModeEventName, devModeQueryStr, devModeStorageKey } from "./constants"; 2 | import { eventHub } from "./events"; 3 | import store from "./store"; 4 | import { FeatureFlagUpdateOperation, IFeatureFlag } from "./types"; 5 | import { addCss, makeElementDraggable } from "./utils"; 6 | import throttleUtil from "./throttleutil"; 7 | 8 | //const DevModeIconBtnUrl = 'https://portal.feature-flags.co/assets/ff_logo.png'; 9 | const DevModeIconBtnData = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEYAAAAhCAYAAABk391mAAAAAXNSR0IArs4c6QAAB1dJREFUaEPdWX1sU1UUP+e2Y90cAoPAUGRxGxJCjCJKoshaWBEEWSVIdOVjUwM4/I4hmkj4VIaAYUZQENgoIxusg62djI05Nk00wY9EDE4HW5iCiEIC2/jc2nfN+2h73+t7fa+jQ+P7o33tO/d37/m9c84951yEKC6bLWdIHDVtoEAfBaDDgMIdgBAHABgFDCDy4giIRLoXfgEg/yn9x98Lv8VvUV4+LjRGkmWfB/AkDBFbjkUpV1N6cO1MtbXrKmSz2cxmOmIRAPcRgEBCDK6QIjwRgg5BRUhIAZkyDIkCaYxcQOGgPBHWKBAryQWICX4DUOrzDS/zrvsramLskxdMA85fBQCWGLDBQCjfsGgpIjeiwmpvOKCoSGM4RsCqeIzQfcjaWIshSE7sPbDqfi29NC3GbnNWAIU5sSUkgKZGjESGCjHhrhfJzUQrES2GJ5m/Ea2NJbzH1DPU7S64EBUxdqvzdwC4p29IEVYqmTn7NnmLCcUAwsQVrRgTxFHEDpEEOZ4sXlFoLq1cOzaSfmEWY4QUs9kM4x4aCyNT7wKLJR46Orrg1Ml2aPm1zSCXKjGGNX+FK8ksgCVMEVtC7ie6EkuuQCKRrIbzjyutWvejYWLsmc4KQG33GTx4IJSUFUJcP+0Y/Fv7WVi6eDl0d/doztv7XYlxEZXdRoartSsB6SytXDNA7w0GLSbLOn8SAveV1oDtuwogLX2kHl7w+cGKWvh0S4mGvF6MEeODegBm4wcbpOU7USjGBGKXaC0c+Efvryw4qadIgBhitzqvA0A/5YBBgwZAWcUWMJnEiaO5/jh7Hp5fuAwoxymG3Y5dSQq+8hzowr7K94Ya0UEgZsqkBXmE+IvVBnxeVwzx8WF8GcEWZL49dhzefXuDKjFsEFbmMay1sC5Cga43ITkmApqlLxOYZDNIv4U/Q08o5//F7d3QYmTxAjFZmTk9iCjNEhpWUrYZUoYbIjjiXBvXb4cjtayX6u1KAEQlFwEkvq4bPyc0NTX5jCh3KzJosz090EwTLylB+HjCx5VYXD6fH2ZMzQVKqQTHbMtEig3M9hqe6ktujOSz6tqtS2KxJj0MzLLlbEKKbykFqw/vAktC7BLewg+L4FB1gzBNeF7CJGVsraSan/DBNDxhYzEDNRii+dX9Ve9v4edc2OZpQQr36REiro+sQrt1XjMAHcMO4Cc50rjXCIZhmStdV2H2rMXaxCiLStmuFNpx2LRfu3QQy4py7wdCqMg7fSiF8/vOCSWZgcuV4UCemIsAdDArPylzAqxY87oBiOhEptrmyVwplKFK23MwjdeqlZgtWbIsmfUxRSQhpjf3VxUUCtbS6t2HQJ81tFrEla707DVot+bcAMB4dtBLL8+HOXOfNIQTjZCSmPC3Ly8ixQAsdz3RSvRcCf1u70Y+CxWCWm6rJxDc9JbLnbh83vLDw0t60G518kmGzMSWvbMEnpieqQcS9XM1iwn2ZQQG2NYCCU/ppTaCVo0U+J8Q87Zyz/p8gZQ2jxsoPGNosQQPuNKyBdl/3WJIFLtSpGpZcCnRlWhF9SbB1PJOF1s4f/I1I7GFAtA9GY5gFot2m/MiUJDHGOsEWLG6D2OMSjeNLRSlnSGs4xZqHURyJbKzonrjIoGYU56VFGGVIWsB2OfKcOQEZHmLaQbA/82uRBK4RLd783WggLltHmUtoslRT5wpuTT1qWA+h/bMeZsB6RvKEX2Zx8hLgdi1NhHJlxXVm2xibPGuBkpXGLIWhJ9c6Y4HWFmcODG7f4I5qVMJkJaeCtt3rTOEqycUKfOV92Vlu1J7Tf2Oe/Ww++q5sBvZrc6bapX17a2V5K1Nzu+fXttYXNdXiuvhSkWk80VE2Kkm3JfVdSjghh2bdNfU7xRyq5yW6iHxJnhcTxEjz3dnzOIb+4auvu/HLFgGlGr3Y9jTgUDs8fm6H6lvKvme12Bhq6cBAaYY0iayULMrwxGxzyuLMYEfT0yZ/xjn577Wwo5lBy9yaxMvH24oSuaz1rnflCckDo2/FgNSgEuyJJWkTLtqFEuW8eodmRjt+eYvWg49Pdo9X60zHz5B46j/tSNH93wsWou3CYFajSoTQe47V4ZjQjQ44acENmc7UEiNBGKOk04JRvKnBBbo6Ojs9SlBKL0Xt+3ahmJhTXP/bkxK7OzsikYZLVmK5tF70mfq9nlVXYn9M8vqPIMAI2KxKHUM9WY4McXl19Tv2MaPyWvzbqWULr3lNRC85ErL5l0zqus/cxIJSG6e+fPKnc3N7m4p6HJGahw9bU2EPFiUNuu4npzyecTGjX3SczMpIW4ESIgWOLI8e+IoupCJmIprvih6QbCWVk8hBbj1Yo3COdcox929WbtuR2v8+PFxyf3H5FLK8SYub8b3ZkaN1mbdUZcYW2i5KaE1/gZi4Aigl5Pw81Bu8u5Rs5t6g6BLDAs6w5aXcpN2r0XgEy4cBkgTKQf9hGI5qksRYwC31jW6XuEhcts8+UDhk6jg1IQRrrrSHUm9xfkHuhb7oJRjgmMAAAAASUVORK5CYII='; 10 | class DevModeEventInit { 11 | key: string = devModeStorageKey; 12 | newValue: any; 13 | oldValue: any; 14 | } 15 | 16 | function addFfEditorFFListChangeListener(ffEditorContainer: HTMLDivElement, featureFlags: { [key: string]: IFeatureFlag }) { 17 | // ff variation change 18 | ffEditorContainer.querySelectorAll("#ffc-ff-editor-container .ff-list").forEach(node => { 19 | node.addEventListener('change', (ev) => { 20 | const target = ev.target; 21 | const id: string = target?.getAttribute('data-id') || ''; 22 | const value = target?.value; 23 | 24 | const ff = featureFlags[id]; 25 | const data = { 26 | id, 27 | oldValue: ff.variation, 28 | newValue: value 29 | }; 30 | 31 | eventHub.emit(`devmode_ff_${FeatureFlagUpdateOperation.update}`, { [id]: data }); 32 | }); 33 | }); 34 | } 35 | 36 | function createFfEditor(featureFlags: { [key: string]: IFeatureFlag }) { 37 | const ffEditorContainer = document.createElement("div"); 38 | ffEditorContainer.id = 'ffc-ff-editor-container'; 39 | ffEditorContainer.innerHTML = ` 40 |
41 |
Developer mode (play with feature flags locally)
42 |
43 |
44 |
45 | 46 |
47 |
48 |
49 |
50 | 51 |
52 | 66 | `; 67 | 68 | let left = "25%"; 69 | let top = "50px"; 70 | addCss(ffEditorContainer, { 71 | "position": "absolute", 72 | "left": left, 73 | "top": top, 74 | "width": "50%", 75 | "z-index": "9999", 76 | "border": "1px grey solid", 77 | "border-radius": "5px", 78 | "box-shadow": "0 8px 8px -4px lightblue", 79 | "background-color": "#fff", 80 | "font-family": `Sohne, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 81 | 'Droid Sans', 'Helvetica Neue', sans-serif` 82 | }); 83 | 84 | ffEditorContainer!.querySelector('#ff-editor-content')!.innerHTML = ffListHtml(featureFlags); 85 | 86 | // search query change 87 | ffEditorContainer.querySelector('#ff-editor-search-query')?.addEventListener('keyup', throttleUtil.throttle((ev) => { 88 | const query = (document.getElementById('ff-editor-search-query'))?.value?.trim() || ''; 89 | if (query && query.length > 0) { 90 | const results = Object.keys(featureFlags).filter(key => key.indexOf(query.toLocaleLowerCase()) !== -1).reduce((res, curr) => { 91 | res[curr] = featureFlags[curr]; 92 | return res; 93 | }, {}) as { [key: string]: IFeatureFlag }; 94 | 95 | ffEditorContainer!.querySelector('#ff-editor-content')!.innerHTML = ffListHtml(results); 96 | } else { 97 | ffEditorContainer!.querySelector('#ff-editor-content')!.innerHTML = ffListHtml(featureFlags); 98 | } 99 | 100 | addFfEditorFFListChangeListener(ffEditorContainer, featureFlags); 101 | }, 200)); 102 | 103 | // close button click handler 104 | ffEditorContainer.querySelector("#ffc-ff-editor-close")?.addEventListener('click', (ev) => { 105 | document.getElementById('ffc-ff-editor-container')?.remove(); 106 | }); 107 | 108 | // reset button click 109 | ffEditorContainer.querySelector("#ff-editor-reset-btn")?.addEventListener('click', (ev) => { 110 | eventHub.emit(`devmode_ff_${FeatureFlagUpdateOperation.createDevData}`, {}); 111 | }); 112 | 113 | // ff variation change 114 | addFfEditorFFListChangeListener(ffEditorContainer, featureFlags); 115 | 116 | document.body.appendChild(ffEditorContainer); 117 | makeElementDraggable(ffEditorContainer); 118 | } 119 | 120 | function ffListHtml(featureFlags: { [key: string]: IFeatureFlag }): string { 121 | return Object.keys(featureFlags).map(key => { 122 | const ff = featureFlags[key]; 123 | 124 | const optionsHtml = ff.variationOptions.map((item) => ``).join(''); 125 | 126 | return ` 127 |
    128 |
  • 129 | 130 | 133 |
  • 134 |
`; 135 | }).join(''); 136 | } 137 | 138 | function enableDevMode() { 139 | if(document.getElementById('ffc-devmode-container')) { 140 | return; 141 | } 142 | 143 | // display dev mode icon 144 | const devModeContainer = document.createElement("div"); 145 | devModeContainer.id = 'ffc-devmode-container'; 146 | addCss(devModeContainer, { 147 | "position": "absolute", 148 | "z-index": "9999", 149 | "bottom": "5px", 150 | "right": "5px", 151 | "font-family": `Sohne, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 152 | 'Droid Sans', 'Helvetica Neue', sans-serif` 153 | }); 154 | 155 | // const closeBtn = document.createElement("div"); 156 | // closeBtn.style.height = '25px'; 157 | // closeBtn.innerHTML = ` 158 | //
159 | // 165 | // `; 166 | 167 | // // add onclick listener on close button, turn off dev mode if clicked 168 | // closeBtn.addEventListener('click', () => { 169 | // localStorage.setItem(devModeStorageKey, `${false}`) 170 | // store.isDevMode = false; 171 | // disableDevMode(); 172 | // }); 173 | 174 | // devModeContainer.appendChild(closeBtn); 175 | 176 | const devModeBtn = document.createElement("img"); 177 | devModeBtn.src = DevModeIconBtnData; 178 | devModeBtn.id = devModeBtnId; 179 | //DevModeIconBtnUrl; 180 | addCss(devModeBtn, { 181 | "padding": "10px", 182 | "z-index": "9999", 183 | "cursor": "pointer", 184 | "width": "70px", 185 | }); 186 | 187 | // add onclick listener on icon 188 | devModeBtn.addEventListener('click', () => { 189 | if(document.getElementById('ffc-ff-editor-container')) { 190 | return; 191 | } 192 | createFfEditor(store.getFeatureFlags()); 193 | }); 194 | 195 | devModeContainer.appendChild(devModeBtn); 196 | document.body.appendChild(devModeContainer); 197 | 198 | makeElementDraggable(devModeContainer); 199 | } 200 | 201 | function disableDevMode() { 202 | document.getElementById("ffc-devmode-container")?.remove(); 203 | document.getElementById("ffc-ff-editor-container")?.remove(); 204 | } 205 | 206 | function dispatchDevModeEvent() { 207 | const setItem = localStorage.setItem; 208 | localStorage.setItem = function (key: string, val: string) { 209 | if (key === devModeStorageKey) { 210 | const devModeStr = localStorage.getItem(devModeStorageKey) || 'false'; 211 | if (devModeStr !== `${val}`) { 212 | let event = new CustomEvent(devModeEventName, { detail: { newValue: `${val}`, oldValue: devModeStr, key } }); 213 | window.dispatchEvent(event); 214 | } 215 | } 216 | 217 | const argumentsTyped: any = arguments; 218 | setItem.apply(this, argumentsTyped); 219 | } 220 | }; 221 | 222 | function onDevModeChange(oldValue: string, newValue: string) { 223 | if (oldValue !== newValue) { 224 | if (newValue === 'true') { 225 | // make sure the document.body exists before enabling dev mode 226 | setTimeout(() => { 227 | store.isDevMode = true; 228 | enableDevMode(); 229 | }, 0); 230 | } else { 231 | // make sure the document.body exists before enabling dev mode 232 | setTimeout(() => { 233 | store.isDevMode = false; 234 | disableDevMode(); 235 | }, 0); 236 | } 237 | } 238 | } 239 | 240 | class DevMode { 241 | private password?: string; 242 | constructor() { 243 | eventHub.subscribe(`devmode_ff_${FeatureFlagUpdateOperation.devDataCreated}`, () => { 244 | createFfEditor(store.getFeatureFlags()); 245 | }); 246 | } 247 | 248 | init(password: string) { 249 | let self = this; 250 | self.password = password; 251 | 252 | if (!this.password) { // if password set, it's not allowed to activate dev mode by setting localStorage 253 | dispatchDevModeEvent(); 254 | window.addEventListener(devModeEventName, function (e) { 255 | const { key, oldValue, newValue } = (e as CustomEvent).detail; 256 | if (key === devModeStorageKey) { 257 | onDevModeChange(oldValue, newValue); 258 | } 259 | }); 260 | 261 | // currently we dont want this feature 262 | // // set devmode from query string 263 | // const queryString = window.location.search; 264 | // const urlParams = new URLSearchParams(queryString); 265 | // const devModeParam = urlParams.get(devModeQueryStr); 266 | // if (devModeParam !== null && ['true', 'false'].findIndex(ele => ele === devModeParam.toLocaleLowerCase()) > -1) { 267 | // localStorage.setItem(devModeStorageKey, devModeParam); 268 | // } 269 | 270 | // if already in dev mode since loading of the page 271 | let devMode = localStorage.getItem(devModeStorageKey) || 'false'; 272 | if (devMode === 'true') { 273 | // make sure the document.body exists before enabling dev mode 274 | setTimeout(() => { 275 | store.isDevMode = true; 276 | enableDevMode(); 277 | }, 0); 278 | } 279 | } else { 280 | // clear localStorage 281 | localStorage.removeItem(devModeStorageKey); 282 | } 283 | } 284 | 285 | activateDevMode(password?: string): void { 286 | if(!this.password || this.password === password){ 287 | localStorage.setItem(devModeStorageKey, `${true}`); 288 | onDevModeChange('', 'true'); 289 | } 290 | } 291 | 292 | openEditor() { 293 | document.getElementById(devModeBtnId)?.click(); 294 | } 295 | 296 | quit(){ 297 | onDevModeChange('', 'false'); 298 | localStorage.setItem(devModeStorageKey, `${false}`); 299 | } 300 | } 301 | 302 | export default new DevMode(); 303 | 304 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | interface Events { 2 | [key: string]: Function[]; 3 | } 4 | 5 | class EventEmitter { 6 | public events: Events; 7 | constructor(events?: Events) { 8 | this.events = events || {}; 9 | } 10 | 11 | public subscribe(name: string, cb: Function) { 12 | (this.events[name] || (this.events[name] = [])).push(cb); 13 | 14 | return { 15 | unsubscribe: () => 16 | this.events[name] && this.events[name].splice(this.events[name].indexOf(cb) >>> 0, 1) 17 | }; 18 | } 19 | 20 | public emit(name: string, ...args: any[]): void { 21 | (this.events[name] || []).forEach(fn => fn(...args)); 22 | } 23 | } 24 | 25 | export const eventHub = new EventEmitter(); -------------------------------------------------------------------------------- /src/ffc.ts: -------------------------------------------------------------------------------- 1 | import devMode from "./devmode"; 2 | import {eventHub} from "./events"; 3 | import {logger} from "./logger"; 4 | import store from "./store"; 5 | import {networkService} from "./network.service"; 6 | import { 7 | FeatureFlagValue, 8 | ICustomEvent, 9 | IFeatureFlag, 10 | IFeatureFlagBase, 11 | IFeatureFlagSet, 12 | IFeatureFlagVariationBuffer, 13 | IInsight, 14 | InsightType, 15 | IOption, 16 | IStreamResponse, 17 | IUser, 18 | StreamResponseEventType, 19 | VariationDataType 20 | } from "./types"; 21 | import {ffcguid, parseVariation, serializeUser, uuid, validateOption, validateUser} from "./utils"; 22 | import {Queue} from "./queue"; 23 | import { 24 | featureFlagEvaluatedBufferTopic, 25 | featureFlagEvaluatedTopic, 26 | insightsFlushTopic, 27 | insightsTopic, 28 | websocketReconnectTopic 29 | } from "./constants"; 30 | import autoCapture from "./autocapture"; 31 | 32 | 33 | function createorGetAnonymousUser(): IUser { 34 | let sessionId = ffcguid(); 35 | var c_name = 'JSESSIONID'; 36 | if (document.cookie.length > 0) { 37 | let c_start = document.cookie.indexOf(c_name + "=") 38 | if (c_start != -1) { 39 | c_start = c_start + c_name.length + 1 40 | let c_end = document.cookie.indexOf(";", c_start) 41 | if (c_end == -1) c_end = document.cookie.length 42 | sessionId = unescape(document.cookie.substring(c_start, c_end)); 43 | } 44 | } 45 | 46 | return { 47 | userName: sessionId, 48 | email: `${sessionId}@anonymous.com`, 49 | id: sessionId 50 | }; 51 | } 52 | 53 | function mapFeatureFlagsToFeatureFlagBaseList(featureFlags: { [key: string]: IFeatureFlag }): IFeatureFlagBase[] { 54 | return Object.keys(featureFlags).map((cur) => { 55 | const { id, variation } = featureFlags[cur]; 56 | const variationType = featureFlags[cur].variationType || VariationDataType.string; 57 | return { id, variation: parseVariation(variationType, variation), variationType }; 58 | }); 59 | } 60 | 61 | export class Ffc { 62 | private _readyEventEmitted: boolean = false; 63 | private _readyPromise: Promise; 64 | 65 | private _insightsQueue: Queue = new Queue(1, insightsFlushTopic); 66 | private _featureFlagEvaluationBuffer: Queue = new Queue(); 67 | private _option: IOption = { 68 | secret: '', 69 | api: 'https://api.featureflag.co', 70 | devModePassword: '', 71 | enableDataSync: true, 72 | appType: 'javascript' 73 | }; 74 | 75 | constructor() { 76 | this._readyPromise = new Promise((resolve, reject) => { 77 | this.on('ready', () => { 78 | const featureFlags = store.getFeatureFlags(); 79 | resolve(mapFeatureFlagsToFeatureFlagBaseList(featureFlags)); 80 | if (this._option.enableDataSync){ 81 | const buffered = this._featureFlagEvaluationBuffer.flush().map(f => { 82 | const featureFlag = featureFlags[f.id]; 83 | if (!featureFlag) { 84 | logger.log(`Called unexisting feature flag: ${f.id}`); 85 | return null; 86 | } 87 | 88 | const variation = featureFlag.variationOptions.find(o => o.value === f.variationValue); 89 | if (!variation) { 90 | logger.log(`Sent buffered insight for feature flag: ${f.id} with unexisting default variation: ${f.variationValue}`); 91 | } else { 92 | logger.logDebug(`Sent buffered insight for feature flag: ${f.id} with variation: ${variation.value}`); 93 | } 94 | 95 | return { 96 | insightType: InsightType.featureFlagUsage, 97 | id: featureFlag.id, 98 | timestamp: f.timestamp, 99 | sendToExperiment: featureFlag.sendToExperiment, 100 | variation: variation || { id: -1, value: f.variationValue } 101 | } 102 | }); 103 | 104 | networkService.sendInsights(buffered.filter(x => !!x)); 105 | } 106 | }); 107 | }); 108 | 109 | // reconnect to websocket 110 | eventHub.subscribe(websocketReconnectTopic, async () => { 111 | try { 112 | logger.logDebug('reconnecting'); 113 | await this.dataSync(); 114 | if (!this._readyEventEmitted) { 115 | this._readyEventEmitted = true; 116 | eventHub.emit('ready', mapFeatureFlagsToFeatureFlagBaseList(store.getFeatureFlags())); 117 | } 118 | }catch(err) { 119 | logger.log('data sync error', err); 120 | } 121 | }); 122 | 123 | eventHub.subscribe(featureFlagEvaluatedBufferTopic, (data: IFeatureFlagVariationBuffer) => { 124 | this._featureFlagEvaluationBuffer.add(data); 125 | }); 126 | 127 | // track feature flag usage data 128 | eventHub.subscribe(insightsFlushTopic, () => { 129 | if (this._option.enableDataSync){ 130 | networkService.sendInsights(this._insightsQueue.flush()); 131 | } 132 | }); 133 | 134 | eventHub.subscribe(featureFlagEvaluatedTopic, (data: IInsight) => { 135 | this._insightsQueue.add(data); 136 | }); 137 | 138 | eventHub.subscribe(insightsTopic, (data: IInsight) => { 139 | this._insightsQueue.add(data); 140 | }); 141 | } 142 | 143 | on(name: string, cb: Function) { 144 | eventHub.subscribe(name, cb); 145 | } 146 | 147 | waitUntilReady(): Promise { 148 | return this._readyPromise; 149 | } 150 | 151 | async init(option: IOption) { 152 | const validateOptionResult = validateOption({...this._option, ...option}); 153 | if (validateOptionResult !== null) { 154 | logger.log(validateOptionResult); 155 | return; 156 | } 157 | 158 | this._option = {...this._option, ...option, ...{ api: (option.api || this._option.api)?.replace(/\/$/, '') }}; 159 | 160 | if (this._option.enableDataSync) { 161 | networkService.init(this._option.api!, this._option.secret, this._option.appType!); 162 | } 163 | 164 | await this.identify(option.user || createorGetAnonymousUser()); 165 | if (this._option.enableDataSync) { 166 | autoCapture.init(); 167 | } 168 | } 169 | 170 | async identify(user: IUser): Promise { 171 | const validateUserResult = validateUser(user); 172 | if (validateUserResult !== null) { 173 | logger.log(validateUserResult); 174 | return; 175 | } 176 | 177 | user.customizedProperties = user.customizedProperties?.map(p => ({name: p.name, value: `${p.value}`})); 178 | 179 | const isUserChanged = serializeUser(user) !== localStorage.getItem('current_user'); 180 | this._option.user = Object.assign({}, user); 181 | localStorage.setItem('current_user', serializeUser(this._option.user)); 182 | 183 | store.userId = this._option.user.id; 184 | networkService.identify(this._option.user, isUserChanged); 185 | 186 | await this.bootstrap(this._option.bootstrap, isUserChanged); 187 | } 188 | 189 | activateDevMode(password: string){ 190 | devMode.activateDevMode(password); 191 | } 192 | 193 | openDevModeEditor() { 194 | devMode.openEditor(); 195 | } 196 | 197 | quitDevMode() { 198 | devMode.quit(); 199 | } 200 | 201 | async logout(): Promise { 202 | const anonymousUser = createorGetAnonymousUser(); 203 | await this.identify(anonymousUser); 204 | return anonymousUser; 205 | } 206 | 207 | /** 208 | * bootstrap with predefined feature flags. 209 | * @param {array} featureFlags the predefined feature flags. 210 | * @param {boolean} forceFullFetch if a forced full fetch should be made. 211 | * @return {Promise} nothing. 212 | */ 213 | async bootstrap(featureFlags?: IFeatureFlag[], forceFullFetch?: boolean): Promise { 214 | featureFlags = featureFlags || this._option.bootstrap; 215 | if (featureFlags && featureFlags.length > 0) { 216 | const data = { 217 | featureFlags: featureFlags.reduce((res, curr) => { 218 | const { id, variation, timestamp, variationOptions, sendToExperiment, variationType } = curr; 219 | res[id] = { id, variation, timestamp, variationOptions: variationOptions || [{id: 1, value: variation}], sendToExperiment, variationType: variationType || VariationDataType.string }; 220 | 221 | return res; 222 | }, {} as { [key: string]: IFeatureFlag }) 223 | }; 224 | 225 | store.setFullData(data); 226 | logger.logDebug('bootstrapped with full data'); 227 | } 228 | 229 | if (this._option.enableDataSync) { 230 | // start data sync 231 | try { 232 | await this.dataSync(forceFullFetch); 233 | }catch(err) { 234 | logger.log('data sync error', err); 235 | } 236 | 237 | store.isDevMode = !!store.isDevMode; 238 | } 239 | 240 | if (!this._readyEventEmitted) { 241 | this._readyEventEmitted = true; 242 | eventHub.emit('ready', mapFeatureFlagsToFeatureFlagBaseList(store.getFeatureFlags())); 243 | } 244 | 245 | devMode.init(this._option.devModePassword || uuid()); 246 | } 247 | 248 | private async dataSync(forceFullFetch?: boolean): Promise { 249 | return new Promise((resolve, reject) => { 250 | const timestamp = forceFullFetch ? 0 : Math.max(...Object.values(store.getFeatureFlags()).map(ff => ff.timestamp), 0); 251 | 252 | networkService.createConnection(timestamp, (message: IStreamResponse) => { 253 | if (message && message.userKeyId === this._option.user?.id) { 254 | const { featureFlags } = message; 255 | 256 | switch (message.eventType) { 257 | case StreamResponseEventType.full: // full data 258 | case StreamResponseEventType.patch: // partial data 259 | const data = { 260 | featureFlags: featureFlags.reduce((res, curr) => { 261 | const { id, variation, timestamp, variationOptions, sendToExperiment, variationType } = curr; 262 | res[id] = { id, variation, timestamp, variationOptions, sendToExperiment, variationType: variationType || VariationDataType.string }; 263 | 264 | return res; 265 | }, {} as { [key: string]: IFeatureFlag }) 266 | }; 267 | 268 | if (message.eventType === StreamResponseEventType.full) { 269 | store.setFullData(data); 270 | logger.logDebug('synchonized with full data'); 271 | } else { 272 | store.updateBulkFromRemote(data); 273 | logger.logDebug('synchonized with partial data'); 274 | } 275 | 276 | break; 277 | default: 278 | logger.logDebug('invalid stream event type: ' + message.eventType); 279 | break; 280 | } 281 | } 282 | 283 | resolve(); 284 | }); 285 | }); 286 | } 287 | 288 | variation(key: string, defaultResult: FeatureFlagValue): FeatureFlagValue { 289 | const variation = variationWithInsightBuffer(key, defaultResult); 290 | return variation === undefined ? defaultResult : variation; 291 | } 292 | 293 | /** 294 | * deprecated, you should use variation method directly 295 | */ 296 | boolVariation(key: string, defaultResult: boolean): boolean { 297 | const variation = variationWithInsightBuffer(key, defaultResult); 298 | return variation === undefined ? defaultResult : variation?.toLocaleLowerCase() === 'true'; 299 | } 300 | 301 | getUser(): IUser { 302 | return { ...this._option.user! }; 303 | } 304 | 305 | sendCustomEvent(data: ICustomEvent[]): void { 306 | (data || []).forEach(d => this._insightsQueue.add({ 307 | insightType: InsightType.customEvent, 308 | type: 'CustomEvent', 309 | ...d 310 | })) 311 | } 312 | 313 | sendFeatureFlagInsight(key: string, variation: string) { 314 | this.variation(key, variation); 315 | } 316 | 317 | getAllFeatureFlags(): IFeatureFlagSet { 318 | const flags = store.getFeatureFlags(); 319 | 320 | return Object.values(flags).reduce((acc, curr) => { 321 | acc[curr.id] = parseVariation(curr.variationType, curr.variation); 322 | return acc; 323 | }, {}); 324 | } 325 | } 326 | 327 | const variationWithInsightBuffer = (key: string, defaultResult: string | boolean) => { 328 | const variation = store.getVariation(key); 329 | if (variation === undefined) { 330 | eventHub.emit(featureFlagEvaluatedBufferTopic, { 331 | id: key, 332 | timestamp: Date.now(), 333 | variationValue: `${defaultResult}` 334 | } as IFeatureFlagVariationBuffer); 335 | } 336 | 337 | return variation; 338 | } 339 | 340 | const ffcClient = new Ffc(); 341 | window['activateFfcDevMode'] = (password: string) => ffcClient.activateDevMode(password); 342 | window['quitFfcDevMode'] = () => ffcClient.quitDevMode(); 343 | 344 | export default ffcClient; 345 | 346 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import ffcClient, { Ffc } from './ffc'; 2 | 3 | 4 | export * from './types'; 5 | 6 | export { Ffc, ffcClient } -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { debugModeQueryStr } from "./constants"; 2 | 3 | // get debug mode from query string 4 | const queryString = window.location.search; 5 | const urlParams = new URLSearchParams(queryString); 6 | const debugModeParam = urlParams.get(debugModeQueryStr); 7 | 8 | export const logger = { 9 | logDebug(...args) { 10 | if (debugModeParam === 'true') { 11 | console.log(...args); 12 | } 13 | }, 14 | 15 | log(...args) { 16 | console.log(...args); 17 | } 18 | } -------------------------------------------------------------------------------- /src/network.service.ts: -------------------------------------------------------------------------------- 1 | import { websocketReconnectTopic } from "./constants"; 2 | import { eventHub } from "./events"; 3 | import { logger } from "./logger"; 4 | import { IExptMetricSetting, IInsight, InsightType, IStreamResponse, IUser, IZeroCode } from "./types"; 5 | import { generateConnectionToken } from "./utils"; 6 | import throttleUtil from "./throttleutil"; 7 | 8 | const socketConnectionIntervals = [250, 500, 1000, 2000, 4000, 8000]; 9 | 10 | class NetworkService { 11 | private user: IUser | undefined; 12 | private api: string | undefined; 13 | private secret: string | undefined; 14 | private appType: string | undefined; 15 | 16 | private retryCounter = 0; 17 | 18 | constructor(){} 19 | 20 | init(api: string, secret: string, appType: string) { 21 | this.api = api; 22 | this.secret = secret; 23 | this.appType = appType; 24 | } 25 | 26 | identify(user: IUser, sendIdentifyMessage: boolean) { 27 | this.user = { ...user }; 28 | throttleUtil.setKey(this.user?.id); 29 | 30 | if (sendIdentifyMessage && this.socket) { 31 | this.sendUserIdentifyMessage(0); 32 | } 33 | } 34 | 35 | private sendUserIdentifyMessage(timestamp: number) { 36 | const { userName, email, country, id, customizedProperties } = this.user!; 37 | const payload = { 38 | messageType: 'data-sync', 39 | data: { 40 | user: { 41 | userName, 42 | email, 43 | country, 44 | userKeyId: id, 45 | customizedProperties, 46 | }, 47 | timestamp 48 | } 49 | }; 50 | 51 | try { 52 | if (this.socket?.readyState === WebSocket.OPEN) { 53 | logger.logDebug('sending user identify message'); 54 | this.socket?.send(JSON.stringify(payload)); 55 | } else { 56 | logger.logDebug(`didn't send user identify message because socket not open`); 57 | } 58 | } catch (err) { 59 | logger.logDebug(err); 60 | } 61 | } 62 | 63 | private socket: WebSocket | undefined | any; 64 | 65 | private reconnect() { 66 | this.socket = null; 67 | const waitTime = socketConnectionIntervals[Math.min(this.retryCounter++, socketConnectionIntervals.length - 1)]; 68 | setTimeout(() => { 69 | logger.logDebug('emit reconnect event'); 70 | eventHub.emit(websocketReconnectTopic, {}); 71 | }, waitTime); 72 | logger.logDebug(waitTime); 73 | } 74 | 75 | private sendPingMessage() { 76 | const payload = { 77 | messageType: 'ping', 78 | data: null 79 | }; 80 | 81 | setTimeout(() => { 82 | try { 83 | if (this.socket?.readyState === WebSocket.OPEN) { 84 | logger.logDebug('sending ping') 85 | this.socket.send(JSON.stringify(payload)); 86 | this.sendPingMessage(); 87 | } else { 88 | logger.logDebug(`socket closed at ${new Date()}`); 89 | this.reconnect(); 90 | } 91 | } catch (err) { 92 | logger.logDebug(err); 93 | } 94 | }, 18000); 95 | } 96 | 97 | createConnection(timestamp: number, onMessage: (response: IStreamResponse) => any) { 98 | const that = this; 99 | if (that.socket) { 100 | onMessage({} as IStreamResponse); 101 | return; 102 | } 103 | 104 | const startTime = Date.now(); 105 | // Create WebSocket connection. 106 | const url = this.api?.replace(/^http/, 'ws') + `/streaming?type=client&token=${generateConnectionToken(this.secret!)}`; 107 | that.socket = new WebSocket(url); 108 | 109 | // Connection opened 110 | that.socket.addEventListener('open', function (this: WebSocket, event) { 111 | that.retryCounter = 0; 112 | // this is the websocket instance to which the current listener is binded to, it's different from that.socket 113 | logger.logDebug(`Connection time: ${Date.now() - startTime} ms`); 114 | that.sendUserIdentifyMessage(timestamp); 115 | that.sendPingMessage(); 116 | }); 117 | 118 | // Connection closed 119 | that.socket.addEventListener('close', function (event) { 120 | logger.logDebug('close'); 121 | if (event.code === 4003) { // do not reconnect when 4003 122 | return; 123 | } 124 | 125 | that.reconnect(); 126 | }); 127 | 128 | // Connection error 129 | that.socket!.addEventListener('error', function (event) { 130 | // reconnect 131 | logger.logDebug('error'); 132 | }); 133 | 134 | // Listen for messages 135 | that.socket.addEventListener('message', function (event) { 136 | const message = JSON.parse(event.data); 137 | if (message.messageType === 'data-sync') { 138 | onMessage(message.data); 139 | if (message.data.featureFlags.length > 0) { 140 | logger.logDebug('socket push update time(ms): ', Date.now() - message.data.featureFlags[0].timestamp); 141 | } 142 | } 143 | }); 144 | } 145 | 146 | private __getUserInfo(): any { 147 | const { userName, email, country, id, customizedProperties } = this.user!; 148 | return { 149 | userName, 150 | email, 151 | country, 152 | keyId: id, 153 | customizedProperties: customizedProperties, 154 | } 155 | } 156 | 157 | sendInsights = throttleUtil.throttleAsync(async (data: IInsight[]): Promise => { 158 | if (!this.secret || !this.user || !data || data.length === 0) { 159 | return; 160 | } 161 | 162 | try { 163 | const payload = [{ 164 | user: this.__getUserInfo(), 165 | userVariations: data.filter(d => d.insightType === InsightType.featureFlagUsage).map(v => ({ 166 | featureFlagKeyName: v.id, 167 | sendToExperiment: v.sendToExperiment, 168 | timestamp: v.timestamp, 169 | variation: { 170 | localId: v.variation!.id, 171 | variationValue: v.variation!.value 172 | } 173 | })), 174 | metrics: data.filter(d => d.insightType !== InsightType.featureFlagUsage).map(d => ({ 175 | route: location.pathname, 176 | numericValue: d.numericValue === null || d.numericValue === undefined? 1 : d.numericValue, 177 | appType: this.appType, 178 | eventName: d.eventName, 179 | type: d.type 180 | })) 181 | }]; 182 | 183 | await post(`${this.api}/api/public/track`, payload, { envSecret: this.secret }); 184 | } catch (err) { 185 | logger.logDebug(err); 186 | } 187 | }) 188 | 189 | async getActiveExperimentMetricSettings(): Promise { 190 | const exptMetricSettingLocalStorageKey = 'ffc_expt_metric'; 191 | try { 192 | const result = await get(`${this.api}/api/public/sdk/experiments`, { envSecret: this.secret! }); 193 | 194 | localStorage.setItem(exptMetricSettingLocalStorageKey, JSON.stringify(result.data)); 195 | return result.data; 196 | } catch (error) { 197 | logger.log(error); 198 | return !!localStorage.getItem(exptMetricSettingLocalStorageKey) ? JSON.parse(localStorage.getItem(exptMetricSettingLocalStorageKey) as string) : []; 199 | } 200 | } 201 | 202 | async getZeroCodeSettings(): Promise { 203 | const zeroCodeSettingLocalStorageKey = 'ffc_zcs'; 204 | try { 205 | const result = await get(`${this.api}/api/public/sdk/zero-code`, { envSecret: this.secret! }); 206 | 207 | localStorage.setItem(zeroCodeSettingLocalStorageKey, JSON.stringify(result.data)); 208 | return result.data; 209 | } catch (error) { 210 | logger.log(error); 211 | return !!localStorage.getItem(zeroCodeSettingLocalStorageKey) ? JSON.parse(localStorage.getItem(zeroCodeSettingLocalStorageKey) as string) : []; 212 | } 213 | } 214 | } 215 | 216 | export const networkService = new NetworkService(); 217 | 218 | export async function post(url: string = '', data: any = {}, headers: { [key: string]: string } = {}) { 219 | try { 220 | const response = await fetch(url, { 221 | method: 'POST', 222 | headers: Object.assign({ 223 | 'Content-Type': 'application/json' 224 | }, headers), 225 | body: JSON.stringify(data) // body data type must match "Content-Type" header 226 | }); 227 | 228 | return response.status === 200 ? response.json() : {}; 229 | } catch (err) { 230 | logger.logDebug(err); 231 | return {}; 232 | } 233 | } 234 | 235 | export async function get(url: string = '', headers: { [key: string]: string } = {}) { 236 | try { 237 | const response = await fetch(url, { 238 | method: 'GET', 239 | headers: Object.assign({ 240 | 'Accept': 'application/json', 241 | 'Content-Type': 'application/json' 242 | }, headers) 243 | }); 244 | 245 | return response.status === 200 ? response.json() : {}; 246 | } catch (err) { 247 | logger.logDebug(err); 248 | return null; 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/queue.ts: -------------------------------------------------------------------------------- 1 | import { eventHub } from "./events"; 2 | 3 | export class Queue { 4 | private queue: T[]; 5 | // flushLimit === 0 means no limit 6 | // and 7 | constructor(private flushLimit: number = 0, private arriveflushLimitTopic: string = '') { 8 | this.queue = []; 9 | } 10 | 11 | add(element: T): void { 12 | this.queue.push(element); 13 | if (this.flushLimit > 0 && this.queue.length >= this.flushLimit) { 14 | eventHub.emit(this.arriveflushLimitTopic, {}); 15 | } 16 | } 17 | 18 | flush(): T[] { 19 | const allElements = [...this.queue]; 20 | this.queue = []; 21 | return allElements; 22 | } 23 | } -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import {featureFlagEvaluatedTopic} from "./constants"; 2 | import {eventHub} from "./events"; 3 | import {logger} from "./logger"; 4 | import { 5 | FeatureFlagUpdateOperation, FeatureFlagValue, 6 | IDataStore, 7 | IFeatureFlag, 8 | IFeatureFlagChange, 9 | InsightType, 10 | VariationDataType 11 | } from "./types"; 12 | import {parseVariation} from "./utils"; 13 | 14 | const DataStoreStorageKey = 'ffcdatastore'; 15 | 16 | class Store { 17 | 18 | private _isDevMode: boolean = false; 19 | private _userId: string | null = null; 20 | 21 | private _store: IDataStore = { 22 | featureFlags: {} as { [key: string]: IFeatureFlag } 23 | } 24 | 25 | constructor() { 26 | eventHub.subscribe(`devmode_ff_${FeatureFlagUpdateOperation.update}`, (data) => { 27 | const updatedFfs = Object.keys(data).map(key => { 28 | const changes = data[key]; 29 | const ff = this._store.featureFlags[key]; 30 | const updatedFf = Object.assign({}, ff, { variation: changes['newValue'], timestamp: Date.now() }); 31 | return updatedFf; 32 | }).reduce((res, curr) => { 33 | res.featureFlags[curr.id] = Object.assign({}, curr, { timestamp: Date.now() }); 34 | return res; 35 | }, { featureFlags: {} }); 36 | 37 | this.updateStorageBulk(updatedFfs, `${DataStoreStorageKey}_dev_${this._userId}`, false); 38 | this._loadFromStorage(); 39 | }); 40 | 41 | eventHub.subscribe(`devmode_ff_${FeatureFlagUpdateOperation.createDevData}`, () => { 42 | localStorage.removeItem(`${DataStoreStorageKey}_dev_${this._userId}`); 43 | this._loadFromStorage(); 44 | eventHub.emit(`devmode_ff_${FeatureFlagUpdateOperation.devDataCreated}`, this._store.featureFlags); 45 | }); 46 | } 47 | 48 | set userId(id: string) { 49 | this._userId = id; 50 | this._loadFromStorage(); 51 | } 52 | 53 | set isDevMode(devMode: boolean) { 54 | this._isDevMode = devMode; 55 | this._loadFromStorage(); 56 | } 57 | 58 | get isDevMode() { 59 | return this._isDevMode; 60 | } 61 | 62 | getFeatureFlag(key: string): IFeatureFlag { 63 | return this._store.featureFlags[key]; 64 | } 65 | 66 | getVariation(key: string): FeatureFlagValue { 67 | const featureFlag = this._store.featureFlags[key]; 68 | 69 | if (!featureFlag) { 70 | return undefined; 71 | } 72 | 73 | eventHub.emit(featureFlagEvaluatedTopic, { 74 | insightType: InsightType.featureFlagUsage, 75 | id: featureFlag.id, 76 | timestamp: Date.now(), 77 | sendToExperiment: featureFlag.sendToExperiment, 78 | variation: featureFlag.variationOptions.find(o => o.value === featureFlag.variation) 79 | }); 80 | 81 | const { variationType, variation } = featureFlag; 82 | 83 | return parseVariation(variationType, variation); 84 | } 85 | 86 | setFullData(data: IDataStore) { 87 | if (!this._isDevMode) { 88 | this._store = { 89 | featureFlags: {} as { [key: string]: IFeatureFlag } 90 | }; 91 | 92 | this._dumpToStorage(this._store); 93 | } 94 | 95 | this.updateBulkFromRemote(data); 96 | } 97 | 98 | getFeatureFlags(): { [key: string]: IFeatureFlag } { 99 | return this._store.featureFlags; 100 | } 101 | 102 | updateStorageBulk(data: IDataStore, storageKey: string, onlyInsertNewElement: boolean) { 103 | let dataStoreStr = localStorage.getItem(storageKey); 104 | let store: IDataStore | null = null; 105 | 106 | try { 107 | if (dataStoreStr && dataStoreStr.trim().length > 0) { 108 | store = JSON.parse(dataStoreStr); 109 | } else if (this.isDevMode || storageKey.indexOf('_dev_') === -1) { 110 | store = { 111 | featureFlags: {} as { [key: string]: IFeatureFlag } 112 | }; 113 | } 114 | } catch (err) { 115 | logger.logDebug(`error while loading local data store: ${storageKey}` + err); 116 | } 117 | 118 | if (!!store) { 119 | const { featureFlags } = data; 120 | 121 | Object.keys(featureFlags).forEach(id => { 122 | const remoteFf = featureFlags[id]; 123 | const localFf = store!.featureFlags[id]; 124 | 125 | const predicate = !localFf || !onlyInsertNewElement; 126 | if (predicate) { 127 | store!.featureFlags[remoteFf.id] = Object.assign({}, remoteFf); 128 | } 129 | }); 130 | 131 | this._dumpToStorage(store, storageKey); 132 | } 133 | } 134 | 135 | updateBulkFromRemote(data: IDataStore) { 136 | const storageKey = `${DataStoreStorageKey}_${this._userId}`; 137 | const devStorageKey = `${DataStoreStorageKey}_dev_${this._userId}`; 138 | 139 | this.updateStorageBulk(data, storageKey, false); 140 | this.updateStorageBulk(data, devStorageKey, true); 141 | 142 | this._loadFromStorage(); 143 | } 144 | 145 | private _emitUpdateEvents(updatedFeatureFlags: any[]): void { 146 | if (updatedFeatureFlags.length > 0) { 147 | updatedFeatureFlags.forEach(({ id, operation, data }) => eventHub.emit(`ff_${operation}:${data.id}`, data)); 148 | eventHub.emit(`ff_${FeatureFlagUpdateOperation.update}`, updatedFeatureFlags.map(item => item.data)); 149 | } 150 | } 151 | 152 | private _dumpToStorage(store?: IDataStore, localStorageKey?: string): void { 153 | if (store) { 154 | const storageKey = localStorageKey || `${DataStoreStorageKey}_${this._userId}`; 155 | localStorage.setItem(storageKey, JSON.stringify(store)); 156 | return; 157 | } 158 | const storageKey = this._isDevMode ? `${DataStoreStorageKey}_dev_${this._userId}` : `${DataStoreStorageKey}_${this._userId}`; 159 | localStorage.setItem(storageKey, JSON.stringify(this._store)); 160 | } 161 | 162 | private _loadFromStorage(): void { 163 | try { 164 | const storageKey = this._isDevMode ? `${DataStoreStorageKey}_dev_${this._userId}` : `${DataStoreStorageKey}_${this._userId}`; 165 | let dataStoreStr = localStorage.getItem(storageKey); 166 | 167 | let shouldDumpToStorage = false; 168 | if (this._isDevMode) { 169 | try { 170 | const devData = JSON.parse(dataStoreStr!); 171 | 172 | if (devData === null || Object.keys(devData.featureFlags).length === 0) { 173 | shouldDumpToStorage = true; 174 | dataStoreStr = localStorage.getItem(`${DataStoreStorageKey}_${this._userId}`); 175 | } 176 | } catch (err) { 177 | shouldDumpToStorage = true; 178 | dataStoreStr = localStorage.getItem(`${DataStoreStorageKey}_${this._userId}`); 179 | } 180 | } 181 | 182 | if (dataStoreStr && dataStoreStr.trim().length > 0) { 183 | // compare _store and dataStoreStr data and send notification if different 184 | const storageData: IDataStore = JSON.parse(dataStoreStr); 185 | 186 | const updatedFeatureFlags = Object.keys(storageData.featureFlags).filter(key => { 187 | const storageFf = storageData.featureFlags[key]; 188 | const ff = this._store.featureFlags[key]; 189 | return !ff || storageFf.variation !== ff.variation || storageFf.variationType !== ff.variationType; 190 | }).map(key => { 191 | const storageFf = storageData.featureFlags[key]; 192 | const ff = this._store.featureFlags[key]; 193 | 194 | return { 195 | id: key, 196 | operation: FeatureFlagUpdateOperation.update, 197 | sendToExperiment: storageFf.sendToExperiment, 198 | data: { 199 | id: key, 200 | oldValue: ff ? parseVariation(ff.variationType, ff.variation): undefined, 201 | newValue: parseVariation(storageFf.variationType, storageFf.variation) 202 | } as IFeatureFlagChange 203 | } 204 | }); 205 | 206 | this._store = storageData; 207 | this._emitUpdateEvents(updatedFeatureFlags); 208 | } else { 209 | this._store = { 210 | featureFlags: {} as { [key: string]: IFeatureFlag } 211 | }; 212 | } 213 | 214 | if (shouldDumpToStorage) { 215 | this._dumpToStorage(); 216 | } 217 | 218 | } catch (err) { 219 | logger.logDebug('error while loading local data store: ' + err); 220 | } 221 | } 222 | } 223 | 224 | export default new Store(); -------------------------------------------------------------------------------- /src/throttleutil.ts: -------------------------------------------------------------------------------- 1 | import { uuid } from "./utils"; 2 | 3 | const API_CALL_RESULTS : {[key: string]: string} = {}; 4 | const FOOT_PRINTS: string[] = []; 5 | let _throttleWait: number = 5 * 60 * 1000; // millionseconds 6 | 7 | class ThrottleUtil { 8 | private _key: string; 9 | 10 | constructor(){ 11 | this._key = uuid(); 12 | } 13 | 14 | setKey(key: string) { 15 | this._key = key || this._key; 16 | } 17 | 18 | throttle(fn: Function, ms: number) { 19 | let timer:any = 0 20 | return function(...args) { 21 | clearTimeout(timer) 22 | timer = setTimeout(fn.bind(null, ...args), ms || 0) 23 | } 24 | } 25 | 26 | throttleAsync (callback: any): any { 27 | let waiting = false; 28 | 29 | let getFootprint = (args: any): string => { 30 | const params = args.map(arg => { 31 | if ( 32 | typeof arg === 'object' && 33 | typeof arg !== "function" && 34 | arg !== null 35 | ) { 36 | if (Array.isArray(arg)) { 37 | return arg.map(a => ({...a, ...{timestamp: null}})) 38 | } else { 39 | return {...arg, ...{timestamp: null}}; 40 | } 41 | } 42 | 43 | return arg; 44 | }); 45 | 46 | return this._key + JSON.stringify(params); 47 | }; 48 | 49 | return async function (...args) { 50 | const footprint = getFootprint(args); 51 | const idx = FOOT_PRINTS.findIndex(f => f === footprint); 52 | if (!waiting || idx === -1) { 53 | waiting = true; 54 | if (idx === -1) { 55 | FOOT_PRINTS.push(footprint); 56 | } 57 | 58 | API_CALL_RESULTS[footprint] = await callback.apply(null, args); 59 | 60 | setTimeout(function () { 61 | waiting = false; 62 | }, _throttleWait); 63 | } 64 | 65 | return API_CALL_RESULTS[footprint]; 66 | } 67 | } 68 | } 69 | 70 | export default new ThrottleUtil(); 71 | 72 | 73 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type FeatureFlagValue = any; 2 | 3 | export interface IFeatureFlagSet { 4 | [key: string]: FeatureFlagValue; 5 | } 6 | 7 | export interface IFeatureFlagChange { 8 | id: string, 9 | oldValue: FeatureFlagValue, 10 | newValue: FeatureFlagValue 11 | } 12 | 13 | export interface IOption { 14 | secret: string, 15 | anonymous?: boolean, 16 | bootstrap?: IFeatureFlag[], 17 | devModePassword?: string, 18 | api?: string, 19 | appType?: string, 20 | user?: IUser, 21 | enableDataSync?: boolean 22 | } 23 | 24 | export interface IUser { 25 | userName: string, 26 | email: string, 27 | country?: string, 28 | id: string, 29 | customizedProperties?: ICustomizedProperty[] 30 | } 31 | 32 | export interface ICustomizedProperty { 33 | name: string, 34 | value: string | number | boolean 35 | } 36 | 37 | export interface IVariationOption { 38 | id: number, 39 | value: FeatureFlagValue 40 | } 41 | 42 | export interface IFeatureFlagVariation { 43 | id?: string, 44 | sendToExperiment?: boolean 45 | timestamp?: number, 46 | variation?: { 47 | id: number, 48 | value: FeatureFlagValue, 49 | } 50 | } 51 | 52 | export interface IFeatureFlagVariationBuffer { 53 | id: string, 54 | timestamp: number, 55 | variationValue: FeatureFlagValue 56 | } 57 | 58 | export enum InsightType { 59 | featureFlagUsage = 1, 60 | customEvent = 2, 61 | pageView = 3, 62 | click = 4 63 | } 64 | 65 | export enum VariationDataType { 66 | string = 'string', 67 | boolean = 'boolean', 68 | number = 'number', 69 | json = 'json', 70 | } 71 | 72 | export interface IInsight extends IFeatureFlagVariation, ICustomEvent { 73 | insightType: InsightType 74 | } 75 | 76 | export interface IFeatureFlagBase { 77 | id: string, // the keyname 78 | variation: FeatureFlagValue, 79 | variationType: VariationDataType 80 | } 81 | 82 | export interface IFeatureFlag extends IFeatureFlagBase{ 83 | sendToExperiment: boolean, 84 | timestamp: number, 85 | variationOptions: IVariationOption[] 86 | } 87 | 88 | export interface IDataStore { 89 | featureFlags: { [key: string]: IFeatureFlag } 90 | } 91 | 92 | export enum StreamResponseEventType { 93 | full = 'full', 94 | patch = 'patch' 95 | } 96 | 97 | export enum FeatureFlagUpdateOperation { 98 | update = 'update', 99 | createDevData = 'createDevData', 100 | devDataCreated = 'devDataCreated' 101 | } 102 | 103 | export interface IStreamResponse { 104 | eventType: StreamResponseEventType, 105 | userKeyId: string, 106 | featureFlags: IFeatureFlag[] 107 | } 108 | 109 | export interface ICustomEvent { 110 | type?: string, 111 | eventName: string, 112 | numericValue?: number 113 | } 114 | 115 | /******************* auto capture begin********************************** */ 116 | export interface IZeroCode { 117 | envId: number, 118 | envSecret: string, 119 | isActive: boolean, 120 | featureFlagId: string, 121 | featureFlagType: FeatureFlagType, 122 | featureFlagKey: string, 123 | items: ICssSelectorItem[] 124 | } 125 | 126 | export interface IHtmlProperty { 127 | id: string, 128 | name: string, 129 | value: string 130 | } 131 | 132 | export interface ICSS { 133 | name: string, 134 | value: string | number 135 | } 136 | 137 | export interface ICssSelectorItem { 138 | cssSelector: string, 139 | variationValue: string, 140 | variationOptionId: number, 141 | action: string, 142 | htmlProperties: IHtmlProperty[], 143 | htmlContent: string, 144 | style: string, 145 | url: string 146 | } 147 | 148 | export enum FeatureFlagType { 149 | Classic = 1, 150 | Pretargeted = 2 // 已经预分流,无需我们的开关做用户分流 151 | } 152 | 153 | export enum EventType { 154 | Custom = 1, 155 | PageView = 2, 156 | Click = 3 157 | } 158 | 159 | export enum UrlMatchType { 160 | Substring = 1 161 | } 162 | 163 | export interface ITargetUrl { 164 | matchType: UrlMatchType, 165 | url: string 166 | } 167 | 168 | export interface IExptMetricSetting { 169 | eventName: string, 170 | eventType: EventType, 171 | elementTargets: string, 172 | targetUrls: ITargetUrl[] 173 | } 174 | /******************* auto capture end********************************** */ -------------------------------------------------------------------------------- /src/umd.ts: -------------------------------------------------------------------------------- 1 | // This file is only for umd version 2 | 3 | // delay showing of page content 4 | const html = document.querySelector('html'); 5 | const waittime = 500; 6 | if (html) { 7 | html.style.visibility = 'hidden'; 8 | setTimeout(() => html.style.visibility = 'visible', waittime); 9 | } 10 | 11 | import Ffc from './ffc'; 12 | import { IOption } from './types'; 13 | import { logger } from './logger'; 14 | 15 | const script = document.querySelector('script[data-ffc-client]'); 16 | const envSecret = script?.getAttribute('data-ffc-client') 17 | 18 | if (script && envSecret) { 19 | const option: IOption = { 20 | secret: envSecret!, 21 | anonymous: true, 22 | }; 23 | Ffc.init(option); 24 | } 25 | 26 | logger.logDebug(`ffc version: __VERSION__`); 27 | 28 | export { Ffc } 29 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import {FeatureFlagValue, ICSS, IOption, IUser, UrlMatchType, VariationDataType} from "./types"; 2 | import {logger} from "./logger"; 3 | 4 | 5 | // generate default user info 6 | export function ffcguid(): string { 7 | let ffcHomePageGuid = localStorage.getItem("ffc-guid"); 8 | if (ffcHomePageGuid) { 9 | return ffcHomePageGuid; 10 | } 11 | else { 12 | const id = uuid(); 13 | localStorage.setItem("ffc-guid", id); 14 | return id; 15 | } 16 | } 17 | 18 | export function serializeUser(user: IUser | undefined): string { 19 | if (!user) { 20 | return ''; 21 | } 22 | 23 | const builtInProperties = `${user.id},${user.userName},${user.email},${user.country}`; 24 | 25 | const customizedProperties = user.customizedProperties?.map(p => `${p.name}:${p.value}`).join(','); 26 | 27 | return `${builtInProperties},${customizedProperties}`; 28 | } 29 | 30 | export function isNumeric(str: string) { 31 | if (typeof str != "string") return false // we only process strings! 32 | // @ts-ignore 33 | return !isNaN(str) && // use type coercion to parse the _entirety_ of the string (`parseFloat` alone does not do this)... 34 | !isNaN(parseFloat(str)) // ...and ensure strings of whitespace fail 35 | } 36 | 37 | export function parseVariation(type: VariationDataType, value: string): FeatureFlagValue { 38 | switch (type) { 39 | case VariationDataType.string: 40 | return value; 41 | case VariationDataType.boolean: 42 | if (value === 'true') { 43 | return true; 44 | } 45 | 46 | if (value === 'false') { 47 | return false; 48 | } 49 | 50 | logger.log(`expected boolean value, but got ${value}`); 51 | return value; 52 | case VariationDataType.number: 53 | if (isNumeric(value)) { 54 | return +value; 55 | } 56 | 57 | logger.log(`expected numeric value, but got ${value}`); 58 | return value; 59 | case VariationDataType.json: 60 | try { 61 | return JSON.parse(value); 62 | } 63 | catch (e) { 64 | logger.log(`expected json value, but got ${value}`); 65 | return value; 66 | } 67 | default: 68 | logger.log(`unexpected variation type ${type} for ${value}`); 69 | return value; 70 | } 71 | } 72 | 73 | export function uuid(): string { 74 | let uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 75 | var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); 76 | return v.toString(16); 77 | }); 78 | 79 | return uuid; 80 | } 81 | 82 | export function validateUser(user: IUser): string | null { 83 | if (!user) { 84 | return 'user must be defined'; 85 | } 86 | 87 | const { id, userName } = user; 88 | 89 | if (id === undefined || id === null || id.trim() === '') { 90 | return 'user id is mandatory'; 91 | } 92 | 93 | if (userName === undefined || userName === null || userName.trim() === '') { 94 | return 'userName is mandatory'; 95 | } 96 | 97 | return null; 98 | } 99 | 100 | export function validateOption(option: IOption): string | null { 101 | if (option === undefined || option === null) { 102 | return 'option is mandatory'; 103 | } 104 | 105 | const { secret, anonymous, user, enableDataSync } = option; 106 | 107 | if (enableDataSync && (secret === undefined || secret === null || secret.trim() === '')) { 108 | return 'secret is mandatory in option'; 109 | } 110 | 111 | // validate user 112 | if (!!anonymous === false && !user) { 113 | return 'user is mandatory when not using anonymous user'; 114 | } 115 | 116 | if (user) { 117 | return validateUser(user); 118 | } 119 | 120 | return null; 121 | } 122 | 123 | /******************** draggable begin ************************/ 124 | export function makeElementDraggable(el) { 125 | el.addEventListener('mousedown', function(this: HTMLElement, e) { 126 | var offsetX = e.clientX - parseInt(window.getComputedStyle(this).left); 127 | var offsetY = e.clientY - parseInt(window.getComputedStyle(this).top); 128 | 129 | function mouseMoveHandler(e) { 130 | e.preventDefault(); 131 | el.style.top = (e.clientY - offsetY) + 'px'; 132 | el.style.left = (e.clientX - offsetX) + 'px'; 133 | } 134 | 135 | function reset() { 136 | document.removeEventListener('mousemove', mouseMoveHandler); 137 | document.removeEventListener('mouseup', reset); 138 | } 139 | 140 | document.addEventListener('mousemove', mouseMoveHandler); 141 | document.addEventListener('mouseup', reset); 142 | }); 143 | } 144 | /******************** draggable end ************************/ 145 | 146 | // add style to html element 147 | export function addCss(element: HTMLElement, style: { [key: string]: string }) { 148 | for (const property in style) { 149 | element.style[property] = style[property]; 150 | } 151 | } 152 | 153 | /********************** encode text begin *****************************/ 154 | const alphabet = { 155 | "0": "Q", 156 | "1": "B", 157 | "2": "W", 158 | "3": "S", 159 | "4": "P", 160 | "5": "H", 161 | "6": "D", 162 | "7": "X", 163 | "8": "Z", 164 | "9": "U", 165 | } 166 | 167 | function encodeNumber(param: number, length: number): string { 168 | var s = "000000000000" + param; 169 | const numberWithLeadingZeros = s.slice(s.length - length); 170 | return numberWithLeadingZeros.split('').map(n => alphabet[n]).join(''); 171 | } 172 | 173 | // generate connection token 174 | export function generateConnectionToken(text: string): string { 175 | text = text.replace(/=*$/, ''); 176 | const timestamp = Date.now(); 177 | const timestampCode = encodeNumber(timestamp, timestamp.toString().length); 178 | // get random number less than the length of the text as the start point, and it must be greater or equal to 2 179 | const start = Math.max(Math.floor(Math.random() * text.length), 2); 180 | 181 | return `${encodeNumber(start, 3)}${encodeNumber(timestampCode.length, 2)}${text.slice(0, start)}${timestampCode}${text.slice(start)}`; 182 | } 183 | 184 | /********************** encode text end *****************************/ 185 | 186 | // test if the current page url mathch the given url 187 | export function isUrlMatch(matchType: UrlMatchType, url: string): boolean { 188 | const current_page_url = window.location.href; 189 | if (url === null || url === undefined || url === '') { 190 | return true; 191 | } 192 | 193 | switch(matchType){ 194 | case UrlMatchType.Substring: 195 | return current_page_url.includes(url); 196 | default: 197 | return false; 198 | } 199 | } 200 | 201 | export function groupBy (xs: any, key: string): {[key: string] : any} { 202 | return xs.reduce(function(rv, x) { 203 | (rv[x[key]] = rv[x[key]] || []).push(x); 204 | return rv; 205 | }, {}); 206 | }; 207 | 208 | export function extractCSS(css: string): ICSS[] { 209 | return css.trim().replace(/(?:\r\n|\r|\n)/g, ';') 210 | .replace(/\w*([\W\w])+\{/g, '') 211 | .replace(/(?:\{|\})/g, '') 212 | .split(';') 213 | .filter(c => c.trim() !== '') 214 | .map(c => { 215 | const style = c.split(':'); 216 | if (style.length === 2) { 217 | return { 218 | name: style[0].trim(), 219 | value: style[1].trim() 220 | } 221 | } 222 | 223 | return { 224 | name: '', 225 | value: '' 226 | } 227 | }) 228 | .filter(s => { 229 | return s.name !== '' && s.value !== '' 230 | }); 231 | } 232 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/gts/tsconfig-google.json", 3 | "compilerOptions": { 4 | "outDir": "esm", 5 | "noImplicitAny": false, 6 | "module": "esnext", 7 | "lib": [ "es2019", "dom" ], 8 | "target": "es5", 9 | "allowJs": true, 10 | "sourceMap": true, 11 | "declaration": true 12 | }, 13 | "include": [ 14 | "src/**/*.ts", 15 | "test/**/*.ts" 16 | ], 17 | "exclude": [ 18 | "src/umd.ts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "./node_modules/gts/tslint.json" 5 | ], 6 | "jsRules": {}, 7 | "rules": {}, 8 | "rulesDirectory": [] 9 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const package = require('./package.json'); 3 | 4 | const baseConfig = { 5 | entry: { 6 | [`ffc-sdk-${package.version}`]: './src/umd.ts', 7 | [`ffc-sdk`]: './src/umd.ts' 8 | }, 9 | devtool: 'source-map', 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.tsx?$/, 14 | use: 'ts-loader', 15 | exclude: /node_modules/, 16 | }, 17 | { 18 | test: /\.ts$/, 19 | loader: 'string-replace-loader', 20 | options: { 21 | search: '__VERSION__', 22 | replace: package.version, 23 | flags: 'g' 24 | } 25 | } 26 | ], 27 | }, 28 | resolve: { 29 | extensions: ['.tsx', '.ts', '.js'], 30 | }, 31 | output: { 32 | path: path.resolve(__dirname, 'umd'), 33 | filename: `[name].js`, 34 | libraryTarget: 'umd', 35 | //library: 'FFCJsClient', 36 | umdNamedDefine: true, 37 | // prevent error: `Uncaught ReferenceError: self is not define` 38 | globalObject: 'this', 39 | }, 40 | optimization: { 41 | minimize: true 42 | }, 43 | }; 44 | 45 | const config = { 46 | ...baseConfig, output: { 47 | path: path.resolve(__dirname, 'umd'), 48 | filename: `[name].js`, 49 | libraryTarget: 'umd', 50 | umdNamedDefine: true, 51 | // prevent error: `Uncaught ReferenceError: self is not define` 52 | globalObject: 'this', 53 | } 54 | }; 55 | 56 | module.exports = [ 57 | config 58 | ]; --------------------------------------------------------------------------------