@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 CHANGED
@@ -1,3 +1,58 @@
1
- # Temporary Holding Version
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
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ }
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }