Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run build
- run: npm test
- run: npm run test
- run: npm run test-browser
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,6 @@ FodyWeavers.xsd

# bundled folder
dist/
dist-esm/
out/
types/

Expand All @@ -411,3 +410,6 @@ types/

# examples
examples/package-lock.json

# playwright test result
test-results
73 changes: 70 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 13 additions & 16 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,24 @@
"name": "@microsoft/feature-management",
"version": "1.0.0-preview",
"description": "Feature Management is a library for enabling/disabling features at runtime. Developers can use feature flags in simple use cases like conditional statement to more advanced scenarios like conditionally adding routes.",
"main": "dist/index.js",
"module": "./dist-esm/index.js",
"main": "./dist/commonjs/index.js",
"module": "./dist/esm/index.js",
"types": "types/index.d.ts",
"files": [
"dist/**/*.js",
"dist/**/*.map",
"dist/**/*.d.ts",
"dist-esm/**/*.js",
"dist-esm/**/*.map",
"dist-esm/**/*.d.ts",
"types/**/*.d.ts",
"dist/",
"types/",
"LICENSE",
"README.md"
],
"scripts": {
"build": "npm run clean && npm run build-cjs && npm run build-esm && npm run build-test",
"build-cjs": "rollup --config",
"build-esm": "tsc -p ./tsconfig.json",
"build": "npm run clean && rollup --config && npm run build-test",
"build-test": "tsc -p ./tsconfig.test.json",
"clean": "rimraf dist dist-esm out types",
"clean": "rimraf dist out types",
"dev": "rollup --config --watch",
"lint": "eslint src/ test/",
"fix-lint": "eslint src/ test/ --fix",
"test": "mocha out/test/*.test.{js,cjs,mjs} --parallel"
"lint": "eslint src/ test/ --ignore-pattern test/browser/testcases.js",
"fix-lint": "eslint src/ test/ --fix --ignore-pattern test/browser/testcases.js",
"test": "mocha out/test/*.test.{js,cjs,mjs} --parallel",
"test-browser": "npx playwright install && npx playwright test"
},
"repository": {
"type": "git",
Expand All @@ -42,6 +36,7 @@
"@types/node": "^20.10.7",
"@typescript-eslint/eslint-plugin": "^6.18.1",
"@typescript-eslint/parser": "^6.18.1",
"@playwright/test": "^1.46.1",
"chai": "^4.4.0",
"chai-as-promised": "^7.1.1",
"eslint": "^8.56.0",
Expand All @@ -51,5 +46,7 @@
"rollup-plugin-dts": "^6.1.0",
"tslib": "^2.6.2",
"typescript": "^5.3.3"
},
"dependencies": {
}
}
15 changes: 15 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './test/browser',
fullyParallel: true,

retries: 0,
reporter: 'list',
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
}
],
});
24 changes: 20 additions & 4 deletions rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,23 @@ export default [
input: "src/index.ts",
output: [
{
file: "dist/index.js",
dir: "dist/commonjs/",
format: "cjs",
sourcemap: true
sourcemap: true,
preserveModules: true,
},
{
dir: "dist/esm/",
format: "esm",
sourcemap: true,
preserveModules: true,
},
{
file: "dist/umd/index.js",
format: "umd",
name: 'FeatureManagement',
sourcemap: true
}
],
plugins: [
typescript({
Expand All @@ -28,13 +41,16 @@ export default [
"strictFunctionTypes": true,
"sourceMap": true,
"inlineSources": true
}
},
"exclude": [
"test/**/*"
]
})
],
},
{
input: "src/index.ts",
output: [{ file: "types/index.d.ts", format: "es" }],
output: [{ file: "types/index.d.ts", format: "esm" }],
plugins: [dts()],
},
];
50 changes: 37 additions & 13 deletions src/filter/TargetingFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// Licensed under the MIT license.

import { IFeatureFilter } from "./FeatureFilter";
import { createHash } from "crypto";

type TargetingFilterParameters = {
Audience: {
Expand Down Expand Up @@ -32,7 +31,7 @@ type TargetingFilterAppContext = {
export class TargetingFilter implements IFeatureFilter {
name: string = "Microsoft.Targeting";

evaluate(context: TargetingFilterEvaluationContext, appContext?: TargetingFilterAppContext): boolean {
async evaluate(context: TargetingFilterEvaluationContext, appContext?: TargetingFilterAppContext): Promise<boolean> {
const { featureName, parameters } = context;
TargetingFilter.#validateParameters(parameters);

Expand Down Expand Up @@ -72,7 +71,7 @@ export class TargetingFilter implements IFeatureFilter {
if (appContext.groups.includes(group.Name)) {
const audienceContextId = constructAudienceContextId(featureName, appContext.userId, group.Name);
const rolloutPercentage = group.RolloutPercentage;
if (TargetingFilter.#isTargeted(audienceContextId, rolloutPercentage)) {
if (await TargetingFilter.#isTargeted(audienceContextId, rolloutPercentage)) {
return true;
}
}
Expand All @@ -84,12 +83,12 @@ export class TargetingFilter implements IFeatureFilter {
return TargetingFilter.#isTargeted(defaultContextId, parameters.Audience.DefaultRolloutPercentage);
}

static #isTargeted(audienceContextId: string, rolloutPercentage: number): boolean {
static async #isTargeted(audienceContextId: string, rolloutPercentage: number): Promise<boolean> {
if (rolloutPercentage === 100) {
return true;
}
// Cryptographic hashing algorithms ensure adequate entropy across hash values.
const contextMarker = stringToUint32(audienceContextId);
const contextMarker = await stringToUint32(audienceContextId);
const contextPercentage = (contextMarker / 0xFFFFFFFF) * 100;
return contextPercentage < rolloutPercentage;
}
Expand Down Expand Up @@ -130,14 +129,39 @@ function constructAudienceContextId(featureName: string, userId: string | undefi
return contextId;
}

function stringToUint32(str: string): number {
// Create a SHA-256 hash of the string
const hash = createHash("sha256").update(str).digest();
async function stringToUint32(str: string): Promise<number> {
let crypto;

// Get the first 4 bytes of the hash
const first4Bytes = hash.subarray(0, 4);
// Check for browser environment
if (typeof window !== "undefined" && window.crypto && window.crypto.subtle) {
crypto = window.crypto;
}
// Check for Node.js environment
else if (typeof global !== "undefined" && global.crypto) {
crypto = global.crypto;
}
// Fallback to native Node.js crypto module
else {
try {
crypto = require("crypto");
} catch (error) {
console.error("Failed to load the crypto module:", error.message);
throw error;
}
}

// Convert the 4 bytes to a uint32 with little-endian encoding
const uint32 = first4Bytes.readUInt32LE(0);
return uint32;
// In the browser, use crypto.subtle.digest
if (crypto.subtle) {
const data = new TextEncoder().encode(str);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const dataView = new DataView(hashBuffer);
const uint32 = dataView.getUint32(0, true);
return uint32;
}
// In Node.js, use the crypto module's hash function
else {
const hash = crypto.createHash("sha256").update(str).digest();
const uint32 = hash.readUInt32LE(0);
return uint32;
}
}
Loading