Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use web api crypto instead of node:crypto for targeting evaluation #25

Merged
merged 14 commits into from
Aug 26, 2024
Merged
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"
zhiyuanliang-ms marked this conversation as resolved.
Show resolved Hide resolved
},
"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',
zhiyuanliang-ms marked this conversation as resolved.
Show resolved Hide resolved
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);
zhiyuanliang-ms marked this conversation as resolved.
Show resolved Hide resolved
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
Loading