@mergedapp/feature-flags 0.1.3

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.
Files changed (114) hide show
  1. package/README.md +651 -0
  2. package/dist/cjs/cli/audit.js +117 -0
  3. package/dist/cjs/cli/cleanup.js +105 -0
  4. package/dist/cjs/cli/config-loader.js +102 -0
  5. package/dist/cjs/cli/generate.js +194 -0
  6. package/dist/cjs/cli/parse-args.js +18 -0
  7. package/dist/cjs/cli.js +46 -0
  8. package/dist/cjs/client.js +505 -0
  9. package/dist/cjs/errors.js +24 -0
  10. package/dist/cjs/index.js +13 -0
  11. package/dist/cjs/jwt.js +85 -0
  12. package/dist/cjs/nestjs/bindings.js +36 -0
  13. package/dist/cjs/nestjs/constants.js +7 -0
  14. package/dist/cjs/nestjs/context.js +28 -0
  15. package/dist/cjs/nestjs/decorators.js +50 -0
  16. package/dist/cjs/nestjs/errors.js +25 -0
  17. package/dist/cjs/nestjs/evaluator.js +87 -0
  18. package/dist/cjs/nestjs/guard.js +67 -0
  19. package/dist/cjs/nestjs/interceptor.js +56 -0
  20. package/dist/cjs/nestjs/module.js +70 -0
  21. package/dist/cjs/nestjs/service.js +54 -0
  22. package/dist/cjs/nestjs/types.js +2 -0
  23. package/dist/cjs/nestjs.js +26 -0
  24. package/dist/cjs/openfeature/context.js +166 -0
  25. package/dist/cjs/openfeature/hooks.js +31 -0
  26. package/dist/cjs/openfeature/server-provider.js +107 -0
  27. package/dist/cjs/openfeature/server.js +13 -0
  28. package/dist/cjs/openfeature/shared.js +83 -0
  29. package/dist/cjs/openfeature/web-provider.js +156 -0
  30. package/dist/cjs/openfeature/web.js +13 -0
  31. package/dist/cjs/package.json +3 -0
  32. package/dist/cjs/persistence.js +249 -0
  33. package/dist/cjs/react/hooks.js +86 -0
  34. package/dist/cjs/react/provider.js +106 -0
  35. package/dist/cjs/react.js +7 -0
  36. package/dist/cjs/remote-evaluator.js +162 -0
  37. package/dist/cjs/types.js +2 -0
  38. package/dist/cli/audit.d.ts +3 -0
  39. package/dist/cli/audit.js +114 -0
  40. package/dist/cli/cleanup.d.ts +3 -0
  41. package/dist/cli/cleanup.js +102 -0
  42. package/dist/cli/config-loader.d.ts +26 -0
  43. package/dist/cli/config-loader.js +66 -0
  44. package/dist/cli/generate.d.ts +3 -0
  45. package/dist/cli/generate.js +191 -0
  46. package/dist/cli/parse-args.d.ts +1 -0
  47. package/dist/cli/parse-args.js +15 -0
  48. package/dist/cli.d.ts +1 -0
  49. package/dist/cli.js +45 -0
  50. package/dist/client.d.ts +67 -0
  51. package/dist/client.js +501 -0
  52. package/dist/errors.d.ts +15 -0
  53. package/dist/errors.js +18 -0
  54. package/dist/index.cjs +1 -0
  55. package/dist/index.d.ts +4 -0
  56. package/dist/index.js +3 -0
  57. package/dist/jwt.d.ts +20 -0
  58. package/dist/jwt.js +78 -0
  59. package/dist/nestjs/bindings.d.ts +5 -0
  60. package/dist/nestjs/bindings.js +33 -0
  61. package/dist/nestjs/constants.d.ts +4 -0
  62. package/dist/nestjs/constants.js +4 -0
  63. package/dist/nestjs/context.d.ts +12 -0
  64. package/dist/nestjs/context.js +24 -0
  65. package/dist/nestjs/decorators.d.ts +4 -0
  66. package/dist/nestjs/decorators.js +45 -0
  67. package/dist/nestjs/errors.d.ts +12 -0
  68. package/dist/nestjs/errors.js +20 -0
  69. package/dist/nestjs/evaluator.d.ts +17 -0
  70. package/dist/nestjs/evaluator.js +83 -0
  71. package/dist/nestjs/guard.d.ts +19 -0
  72. package/dist/nestjs/guard.js +63 -0
  73. package/dist/nestjs/interceptor.d.ts +10 -0
  74. package/dist/nestjs/interceptor.js +53 -0
  75. package/dist/nestjs/module.d.ts +6 -0
  76. package/dist/nestjs/module.js +67 -0
  77. package/dist/nestjs/service.d.ts +30 -0
  78. package/dist/nestjs/service.js +51 -0
  79. package/dist/nestjs/types.d.ts +100 -0
  80. package/dist/nestjs/types.js +1 -0
  81. package/dist/nestjs.cjs +1 -0
  82. package/dist/nestjs.d.ts +10 -0
  83. package/dist/nestjs.js +9 -0
  84. package/dist/openfeature/context.d.ts +10 -0
  85. package/dist/openfeature/context.js +160 -0
  86. package/dist/openfeature/hooks.d.ts +6 -0
  87. package/dist/openfeature/hooks.js +27 -0
  88. package/dist/openfeature/server-provider.d.ts +20 -0
  89. package/dist/openfeature/server-provider.js +102 -0
  90. package/dist/openfeature/server.cjs +1 -0
  91. package/dist/openfeature/server.d.ts +3 -0
  92. package/dist/openfeature/server.js +3 -0
  93. package/dist/openfeature/shared.d.ts +37 -0
  94. package/dist/openfeature/shared.js +74 -0
  95. package/dist/openfeature/web-provider.d.ts +27 -0
  96. package/dist/openfeature/web-provider.js +151 -0
  97. package/dist/openfeature/web.cjs +1 -0
  98. package/dist/openfeature/web.d.ts +3 -0
  99. package/dist/openfeature/web.js +3 -0
  100. package/dist/persistence.d.ts +39 -0
  101. package/dist/persistence.js +203 -0
  102. package/dist/react/hooks.d.ts +52 -0
  103. package/dist/react/hooks.js +78 -0
  104. package/dist/react/provider.d.ts +71 -0
  105. package/dist/react/provider.js +99 -0
  106. package/dist/react.cjs +1 -0
  107. package/dist/react.d.ts +2 -0
  108. package/dist/react.js +2 -0
  109. package/dist/remote-evaluator.d.ts +28 -0
  110. package/dist/remote-evaluator.js +158 -0
  111. package/dist/types.d.ts +56 -0
  112. package/dist/types.js +1 -0
  113. package/featureflags.config.schema.json +38 -0
  114. package/package.json +107 -0
@@ -0,0 +1,158 @@
1
+ import { FeatureFlagNetworkError, FeatureFlagVerificationError } from "./errors.js";
2
+ import { importPublicKey, resetPublicKeyCache, verifyFeatureFlagTokenDetailed } from "./jwt.js";
3
+ import { serializeCanonicalValue } from "./persistence.js";
4
+ const REMOTE_FETCH_TIMEOUT_MS = 5_000;
5
+ export class RemoteFeatureFlagEvaluator {
6
+ publicKeyPem;
7
+ publicKeyCrypto = null;
8
+ evaluationCache = new Map();
9
+ inflightEvaluations = new Map();
10
+ apiUrl;
11
+ clientKey;
12
+ organizationId;
13
+ environmentId;
14
+ teamId;
15
+ constructor(config) {
16
+ this.apiUrl = config.apiUrl.replace(/\/$/, "");
17
+ this.clientKey = config.clientKey;
18
+ this.organizationId = config.organizationId.trim();
19
+ this.environmentId = config.environmentId.trim();
20
+ this.teamId = config.teamId?.trim() || null;
21
+ this.publicKeyPem = config.publicKey ?? null;
22
+ }
23
+ async evaluate(context) {
24
+ const contextKey = serializeCanonicalValue(context ?? null);
25
+ const cached = this.evaluationCache.get(contextKey);
26
+ if (cached && this.isCachedEvaluationValid(cached)) {
27
+ return cached;
28
+ }
29
+ if (cached) {
30
+ this.evaluationCache.delete(contextKey);
31
+ }
32
+ const inflight = this.inflightEvaluations.get(contextKey);
33
+ if (inflight) {
34
+ return inflight;
35
+ }
36
+ const evaluationPromise = this.evaluateUncached(context).finally(() => {
37
+ this.inflightEvaluations.delete(contextKey);
38
+ });
39
+ this.inflightEvaluations.set(contextKey, evaluationPromise);
40
+ const evaluation = await evaluationPromise;
41
+ if (this.isCachedEvaluationValid(evaluation)) {
42
+ this.evaluationCache.set(contextKey, evaluation);
43
+ }
44
+ else {
45
+ this.evaluationCache.delete(contextKey);
46
+ }
47
+ return evaluation;
48
+ }
49
+ async evaluateUncached(context) {
50
+ await this.ensureVerificationKey();
51
+ const response = await this.fetchSignedFlags(context ?? null);
52
+ const verified = await this.verifyWithRetry(response.token);
53
+ return {
54
+ expiresAt: verified.expiresAt,
55
+ fetchedAt: new Date().toISOString(),
56
+ flags: verified.flags,
57
+ };
58
+ }
59
+ isCachedEvaluationValid(evaluation) {
60
+ if (!evaluation.expiresAt) {
61
+ return false;
62
+ }
63
+ const expiresAt = new Date(evaluation.expiresAt).getTime();
64
+ return Number.isFinite(expiresAt) && expiresAt > Date.now();
65
+ }
66
+ async fetchSignedFlags(context) {
67
+ const url = new URL("/api/feature-flags/evaluate/signed", this.apiUrl);
68
+ const payload = {
69
+ organizationId: this.organizationId,
70
+ environmentId: this.environmentId,
71
+ ...(this.teamId ? { teamId: this.teamId } : {}),
72
+ ...(context ? { context } : {}),
73
+ };
74
+ let response;
75
+ try {
76
+ response = await this.fetchWithTimeout(url, {
77
+ method: "POST",
78
+ headers: {
79
+ Authorization: `Bearer ${this.clientKey}`,
80
+ "Content-Type": "application/json",
81
+ Accept: "application/json",
82
+ },
83
+ body: JSON.stringify(payload),
84
+ });
85
+ }
86
+ catch (error) {
87
+ if (error instanceof FeatureFlagNetworkError) {
88
+ throw error;
89
+ }
90
+ throw new FeatureFlagNetworkError("Failed to fetch feature flags.", { cause: error });
91
+ }
92
+ if (!response.ok) {
93
+ throw new FeatureFlagNetworkError(`Feature flag API returned ${response.status}: ${response.statusText}`);
94
+ }
95
+ return (await response.json());
96
+ }
97
+ async fetchPublicKey() {
98
+ const url = `${this.apiUrl}/api/feature-flags/public-key`;
99
+ let response;
100
+ try {
101
+ response = await this.fetchWithTimeout(url);
102
+ }
103
+ catch (error) {
104
+ if (error instanceof FeatureFlagNetworkError) {
105
+ throw error;
106
+ }
107
+ throw new FeatureFlagNetworkError("Failed to fetch public key.", { cause: error });
108
+ }
109
+ if (!response.ok) {
110
+ throw new FeatureFlagNetworkError(`Public key endpoint returned ${response.status}: ${response.statusText}`);
111
+ }
112
+ const body = (await response.json());
113
+ return body.publicKey;
114
+ }
115
+ async verifyWithRetry(token) {
116
+ try {
117
+ return await verifyFeatureFlagTokenDetailed({ token, publicKey: this.publicKeyCrypto });
118
+ }
119
+ catch (error) {
120
+ if (!(error instanceof FeatureFlagVerificationError)) {
121
+ throw error;
122
+ }
123
+ resetPublicKeyCache();
124
+ this.publicKeyPem = await this.fetchPublicKey();
125
+ this.publicKeyCrypto = await importPublicKey(this.publicKeyPem);
126
+ return verifyFeatureFlagTokenDetailed({ token, publicKey: this.publicKeyCrypto });
127
+ }
128
+ }
129
+ async ensureVerificationKey() {
130
+ if (!this.publicKeyPem) {
131
+ this.publicKeyPem = await this.fetchPublicKey();
132
+ }
133
+ if (!this.publicKeyCrypto) {
134
+ this.publicKeyCrypto = await importPublicKey(this.publicKeyPem);
135
+ }
136
+ }
137
+ async fetchWithTimeout(input, init) {
138
+ const controller = new AbortController();
139
+ const timeout = setTimeout(() => {
140
+ controller.abort();
141
+ }, REMOTE_FETCH_TIMEOUT_MS);
142
+ try {
143
+ return await fetch(input, {
144
+ ...init,
145
+ signal: controller.signal,
146
+ });
147
+ }
148
+ catch (error) {
149
+ if (error instanceof Error && error.name === "AbortError") {
150
+ throw new FeatureFlagNetworkError(`Feature flag request timed out after ${REMOTE_FETCH_TIMEOUT_MS}ms.`, { cause: error });
151
+ }
152
+ throw error;
153
+ }
154
+ finally {
155
+ clearTimeout(timeout);
156
+ }
157
+ }
158
+ }
@@ -0,0 +1,56 @@
1
+ export type { EvaluatedFlag, FeatureFlagEvaluationContext, FeatureFlagType, SignedEvaluateResponse, EvaluateResponse, PublicKeyResponse, FeatureFlagDefinition, } from "@repo/types-v2";
2
+ export type { FeatureFlagError, FeatureFlagNetworkError, FeatureFlagVerificationError } from "./errors.js";
3
+ export type FlagRegistry = object;
4
+ export type FeatureFlagRuntimeSource = "network" | "persisted" | "defaults";
5
+ export type FeatureFlagRuntimeStatus = {
6
+ source: FeatureFlagRuntimeSource;
7
+ isStale: boolean;
8
+ lastSuccessfulRefreshAt: string | null;
9
+ tokenExpiresAt: string | null;
10
+ lastError: Error | null;
11
+ };
12
+ export type PersistedFeatureFlagSnapshot = {
13
+ schemaVersion: number;
14
+ scopeKey: string;
15
+ organizationId: string;
16
+ environmentId: string;
17
+ teamId: string | null;
18
+ clientKeyFingerprint: string;
19
+ contextHash: string;
20
+ token: string;
21
+ publicKeyPem: string;
22
+ fetchedAt: string;
23
+ tokenExpiresAt: string | null;
24
+ };
25
+ export type FeatureFlagSnapshotStore = {
26
+ load(key: string): Promise<PersistedFeatureFlagSnapshot | null>;
27
+ save(key: string, snapshot: PersistedFeatureFlagSnapshot): Promise<void>;
28
+ remove(key: string): Promise<void>;
29
+ };
30
+ export type FeatureFlagSnapshotPersistenceConfig = {
31
+ store?: FeatureFlagSnapshotStore;
32
+ keyPrefix?: string;
33
+ };
34
+ export type FeatureFlagClientConfig<TFlags extends FlagRegistry = FlagRegistry> = {
35
+ clientKey: string;
36
+ apiUrl: string;
37
+ organizationId: string;
38
+ environmentId: string;
39
+ teamId?: string;
40
+ flagIds?: {
41
+ readonly [K in string & keyof TFlags]: string;
42
+ };
43
+ publicKey?: string;
44
+ refreshInterval?: number;
45
+ evaluationContext?: import("@repo/types-v2").FeatureFlagEvaluationContext;
46
+ snapshotPersistence?: false | FeatureFlagSnapshotPersistenceConfig;
47
+ onError?: (error: Error) => void;
48
+ onFlagsChanged?: (flags: import("@repo/types-v2").EvaluatedFlag[]) => void;
49
+ };
50
+ export type GenerateConfig = {
51
+ apiUrl: string;
52
+ clientKey: string;
53
+ outputPath: string;
54
+ teamId?: string;
55
+ organizationId: string;
56
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Merged feature flags CLI config",
4
+ "description": "Configuration for merged-ff code generation and related CLI workflows.",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "$schema": {
9
+ "type": "string",
10
+ "description": "Optional JSON Schema URL used by IDEs for autocomplete and validation."
11
+ },
12
+ "apiUrl": {
13
+ "type": "string",
14
+ "description": "Base URL for the feature flags API.",
15
+ "format": "uri"
16
+ },
17
+ "clientKey": {
18
+ "type": "string",
19
+ "description": "API key used for fetching feature flag definitions."
20
+ },
21
+ "organizationId": {
22
+ "type": "string",
23
+ "description": "Organization scope used when fetching feature flag definitions."
24
+ },
25
+ "teamId": {
26
+ "type": "string",
27
+ "description": "Optional team scope used when fetching feature flag definitions."
28
+ },
29
+ "outputPath": {
30
+ "type": "string",
31
+ "description": "Output path for the generated feature flags TypeScript module."
32
+ },
33
+ "environmentId": {
34
+ "type": "string",
35
+ "description": "Optional environment identifier for app-local conventions. The generate command ignores this field."
36
+ }
37
+ }
38
+ }
package/package.json ADDED
@@ -0,0 +1,107 @@
1
+ {
2
+ "name": "@mergedapp/feature-flags",
3
+ "version": "0.1.3",
4
+ "type": "module",
5
+ "description": "Type-safe client SDK for Merged feature flags with ES256 JWT verification",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "bin": {
10
+ "merged-ff": "./dist/cli.js"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "require": "./dist/index.cjs"
17
+ },
18
+ "./react": {
19
+ "types": "./dist/react.d.ts",
20
+ "import": "./dist/react.js",
21
+ "require": "./dist/react.cjs"
22
+ },
23
+ "./openfeature/web": {
24
+ "types": "./dist/openfeature/web.d.ts",
25
+ "import": "./dist/openfeature/web.js",
26
+ "require": "./dist/openfeature/web.cjs"
27
+ },
28
+ "./openfeature/server": {
29
+ "types": "./dist/openfeature/server.d.ts",
30
+ "import": "./dist/openfeature/server.js",
31
+ "require": "./dist/openfeature/server.cjs"
32
+ },
33
+ "./nestjs": {
34
+ "types": "./dist/nestjs.d.ts",
35
+ "import": "./dist/nestjs.js",
36
+ "require": "./dist/nestjs.cjs"
37
+ },
38
+ "./featureflags.config.schema.json": "./featureflags.config.schema.json"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "featureflags.config.schema.json",
46
+ "!**/*.map"
47
+ ],
48
+ "scripts": {
49
+ "build": "node ./scripts/build.mjs",
50
+ "dev": "node ./scripts/build.mjs",
51
+ "test": "vitest run",
52
+ "check-types": "tsc --noEmit"
53
+ },
54
+ "dependencies": {
55
+ "jose": "^6.0.0"
56
+ },
57
+ "peerDependencies": {
58
+ "@nestjs/common": "^11.0.0",
59
+ "@nestjs/core": "^11.0.0",
60
+ "@openfeature/core": "^1.9.2",
61
+ "@openfeature/server-sdk": "^1.20.2",
62
+ "@openfeature/web-sdk": "^1.7.3",
63
+ "react": "^18.0.0 || ^19.0.0",
64
+ "rxjs": "^7.0.0"
65
+ },
66
+ "peerDependenciesMeta": {
67
+ "@nestjs/common": {
68
+ "optional": true
69
+ },
70
+ "@nestjs/core": {
71
+ "optional": true
72
+ },
73
+ "@openfeature/core": {
74
+ "optional": true
75
+ },
76
+ "@openfeature/server-sdk": {
77
+ "optional": true
78
+ },
79
+ "@openfeature/web-sdk": {
80
+ "optional": true
81
+ },
82
+ "react": {
83
+ "optional": true
84
+ },
85
+ "rxjs": {
86
+ "optional": true
87
+ }
88
+ },
89
+ "devDependencies": {
90
+ "@nestjs/common": "^11.0.1",
91
+ "@nestjs/core": "^11.0.1",
92
+ "@openfeature/core": "^1.9.2",
93
+ "@openfeature/server-sdk": "^1.20.2",
94
+ "@openfeature/web-sdk": "^1.7.3",
95
+ "@repo/types-v2": "workspace:*",
96
+ "@repo/typescript-config": "workspace:*",
97
+ "@types/node": "^25.5.0",
98
+ "@types/react": "^19.1.4",
99
+ "@types/react-dom": "^19.1.5",
100
+ "react": "^19.2.0",
101
+ "react-dom": "^19.2.0",
102
+ "reflect-metadata": "^0.2.2",
103
+ "rxjs": "^7.8.1",
104
+ "typescript": "6.0.3",
105
+ "vitest": "^3.0.5"
106
+ }
107
+ }