@maksymshypytsia/flags-sdk 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -2
- package/dist/hashRollout.d.ts +6 -0
- package/dist/hashRollout.js +31 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +48 -0
- package/package.json +45 -4
package/README.md
CHANGED
|
@@ -1,3 +1,58 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @maksymshypytsia/flags-sdk
|
|
2
|
+
|
|
3
|
+
Tiny client for the [feature-flags-saas](https://github.com/ShipaM/feature-flags-saas)
|
|
4
|
+
service. It makes **one** request, caches the result in memory and then decides
|
|
5
|
+
locally and deterministically whether a flag is on for a user.
|
|
6
|
+
No polling, no WebSocket.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @maksymshypytsia/flags-sdk
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { getFlags } from "@maksymshypytsia/flags-sdk";
|
|
18
|
+
const flags = await getFlags({
|
|
19
|
+
apiKey: "ffk_prod_...", // dashboard -> Project -> API keys
|
|
20
|
+
userKey: "user-42", // any stable id: user id, email, ...
|
|
21
|
+
baseUrl: "https://flags.example.com", // where the service runs
|
|
22
|
+
});
|
|
23
|
+
if (flags.isEnabled("new-checkout")) {
|
|
24
|
+
// show the new checkout
|
|
25
|
+
}
|
|
26
|
+
```
|
|
2
27
|
|
|
3
|
-
|
|
28
|
+
## Development
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install # install dev dependencies
|
|
32
|
+
npm run typecheck # tsc --noEmit, type errors only
|
|
33
|
+
npm test # vitest run
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Build and pack
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm run build # tsc -> dist/ (old dist/ is deleted first)
|
|
40
|
+
ls dist # hashRollout.d.ts hashRollout.js index.d.ts index.js
|
|
41
|
+
npm pack --dry-run # list what goes into the package, creates nothing
|
|
42
|
+
npm pack # create maksymshypytsia-flags-sdk-0.1.0.tgz
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Only `dist/`, `README.md` and `package.json` end up in the package
|
|
46
|
+
(see `"files"` in `package.json`). Test files are excluded from the build
|
|
47
|
+
by `tsconfig.build.json`.
|
|
48
|
+
|
|
49
|
+
### Try the package locally
|
|
50
|
+
|
|
51
|
+
Install the `.tgz` into any other project to check it before publishing:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cd ../some-app
|
|
55
|
+
npm install ../feature-flags-sdk/maksymshypytsia-flags-sdk-0.1.0.tgz
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`*.tgz` and `dist/` are in `.gitignore`, so they are never committed.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Fast, non-cryptographic hash. Returns a number from 0 to 2^32 - 1. */
|
|
2
|
+
export declare function fnv1a(input: string): number;
|
|
3
|
+
/** Get a user's bucket from 0 to 99 for a specific flag. */
|
|
4
|
+
export declare function getBucket(userKey: string, flagKey: string): number;
|
|
5
|
+
/** Check if a flag is enabled for a user at the given rollout percentage. */
|
|
6
|
+
export declare function isEnabledForUser(userKey: string, flagKey: string, rolloutPercentage: number): boolean;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Deterministic rollout: the same user and flag always get the same result.
|
|
2
|
+
const FNV_OFFSET_BASIS = 0x811c9dc5; // Initial value for FNV-1a (32-bit).
|
|
3
|
+
const FNV_PRIME = 0x01000193; // FNV-1a multiplication constant.
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
/** Fast, non-cryptographic hash. Returns a number from 0 to 2^32 - 1. */
|
|
6
|
+
export function fnv1a(input) {
|
|
7
|
+
let hash = FNV_OFFSET_BASIS;
|
|
8
|
+
// Hash the UTF-8 bytes so different languages get the same result.
|
|
9
|
+
for (const byte of encoder.encode(input)) {
|
|
10
|
+
hash ^= byte; // Mix the byte into the hash.
|
|
11
|
+
hash = Math.imul(hash, FNV_PRIME); // Multiply using 32-bit arithmetic.
|
|
12
|
+
}
|
|
13
|
+
return hash >>> 0; // Convert the result to an unsigned 32-bit number.
|
|
14
|
+
}
|
|
15
|
+
/** Get a user's bucket from 0 to 99 for a specific flag. */
|
|
16
|
+
export function getBucket(userKey, flagKey) {
|
|
17
|
+
// Combine user and flag so each flag gets its own rollout.
|
|
18
|
+
return fnv1a(`${userKey}:${flagKey}`) % 100;
|
|
19
|
+
}
|
|
20
|
+
/** Check if a flag is enabled for a user at the given rollout percentage. */
|
|
21
|
+
export function isEnabledForUser(userKey, flagKey, rolloutPercentage) {
|
|
22
|
+
// Handle edge cases explicitly.
|
|
23
|
+
if (Number.isNaN(rolloutPercentage))
|
|
24
|
+
return false;
|
|
25
|
+
if (rolloutPercentage <= 0)
|
|
26
|
+
return false;
|
|
27
|
+
if (rolloutPercentage >= 100)
|
|
28
|
+
return true;
|
|
29
|
+
// Enable the flag if the user's bucket is inside the rollout range.
|
|
30
|
+
return getBucket(userKey, flagKey) < rolloutPercentage;
|
|
31
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { isEnabledForUser } from "./hashRollout.js";
|
|
2
|
+
export { isEnabledForUser };
|
|
3
|
+
/** One flag exactly as GET /api/v1/flags returns it. */
|
|
4
|
+
export type FlagData = {
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
rollout: number;
|
|
7
|
+
};
|
|
8
|
+
export type GetFlagsOptions = {
|
|
9
|
+
/** Project API key, e.g. "ffk_prod_...". Sent as "Authorization: Bearer <key>". */
|
|
10
|
+
apiKey: string;
|
|
11
|
+
/** Any stable id of the end user (user id, email...). Decides the rollout bucket. */
|
|
12
|
+
userKey: string;
|
|
13
|
+
/** Where the feature-flags service lives, e.g. "https://flags.example.com". */
|
|
14
|
+
baseUrl: string;
|
|
15
|
+
};
|
|
16
|
+
export type FlagsClient = {
|
|
17
|
+
/** true if the flag exists, is enabled and the user is inside its rollout. */
|
|
18
|
+
isEnabled(flagKey: string): boolean;
|
|
19
|
+
};
|
|
20
|
+
/** Forget cached flags. The next getFlags() call fetches again. */
|
|
21
|
+
export declare function clearCache(): void;
|
|
22
|
+
export declare function getFlags(options: GetFlagsOptions): Promise<FlagsClient>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isEnabledForUser } from "./hashRollout.js";
|
|
2
|
+
// Re-exported so a server can reuse the exact same rollout logic as the SDK.
|
|
3
|
+
export { isEnabledForUser };
|
|
4
|
+
// In-memory cache: ONE network request per baseUrl + apiKey.
|
|
5
|
+
// We store the Promise, so parallel getFlags() calls share the same request.
|
|
6
|
+
const cache = new Map();
|
|
7
|
+
/** Forget cached flags. The next getFlags() call fetches again. */
|
|
8
|
+
export function clearCache() {
|
|
9
|
+
cache.clear();
|
|
10
|
+
}
|
|
11
|
+
async function fetchFlags(apiKey, baseUrl) {
|
|
12
|
+
const res = await fetch(`${baseUrl}/api/v1/flags`, {
|
|
13
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
14
|
+
});
|
|
15
|
+
if (!res.ok)
|
|
16
|
+
throw new Error(`Failed to load flags: HTTP ${res.status}`);
|
|
17
|
+
const body = (await res.json());
|
|
18
|
+
if (!body || typeof body.flags !== "object" || body.flags === null) {
|
|
19
|
+
throw new Error("Failed to load flags: unexpected response format");
|
|
20
|
+
}
|
|
21
|
+
return body.flags;
|
|
22
|
+
}
|
|
23
|
+
export async function getFlags(options) {
|
|
24
|
+
const { apiKey, userKey, baseUrl } = options;
|
|
25
|
+
if (!apiKey)
|
|
26
|
+
throw new TypeError("getFlags: apiKey is required");
|
|
27
|
+
if (!userKey)
|
|
28
|
+
throw new TypeError("getFlags: userKey is required");
|
|
29
|
+
if (!baseUrl)
|
|
30
|
+
throw new TypeError("getFlags: baseUrl is required");
|
|
31
|
+
const base = baseUrl.replace(/\/+$/, ""); // "https://x.com/" -> "https://x.com"
|
|
32
|
+
const cacheKey = `${base}|${apiKey}`;
|
|
33
|
+
let pending = cache.get(cacheKey);
|
|
34
|
+
if (!pending) {
|
|
35
|
+
pending = fetchFlags(apiKey, base);
|
|
36
|
+
cache.set(cacheKey, pending);
|
|
37
|
+
pending.catch(() => cache.delete(cacheKey)); // never cache a failure
|
|
38
|
+
}
|
|
39
|
+
const data = await pending;
|
|
40
|
+
return {
|
|
41
|
+
isEnabled(flagKey) {
|
|
42
|
+
const flag = data[flagKey];
|
|
43
|
+
if (!flag || !flag.enabled)
|
|
44
|
+
return false; // unknown or switched off
|
|
45
|
+
return isEnabledForUser(userKey, flagKey, flag.rollout);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maksymshypytsia/flags-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tiny client SDK for feature-flags-saas: one fetch, local evaluation.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"prebuild": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
26
|
+
"build": "tsc -p tsconfig.build.json",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"prepare": "npm run build",
|
|
30
|
+
"prepublishOnly": "npm run typecheck && npm test"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"feature-flags",
|
|
34
|
+
"feature-toggles",
|
|
35
|
+
"sdk"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/ShipaM/feature-flags-sdk.git"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^22.20.5",
|
|
44
|
+
"typescript": "^5",
|
|
45
|
+
"vitest": "^5.0.5"
|
|
46
|
+
}
|
|
47
|
+
}
|