@anterprize/fturex 0.0.1
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/LICENSE +21 -0
- package/README.md +226 -0
- package/dist/FtureXClient.d.ts +72 -0
- package/dist/FtureXClient.js +427 -0
- package/dist/angular/feature-toggle.directive.d.ts +32 -0
- package/dist/angular/feature-toggle.directive.js +77 -0
- package/dist/angular/feature-toggle.pipe.d.ts +20 -0
- package/dist/angular/feature-toggle.pipe.js +37 -0
- package/dist/angular/fturex.config.d.ts +7 -0
- package/dist/angular/fturex.config.js +2 -0
- package/dist/angular/fturex.module.d.ts +23 -0
- package/dist/angular/fturex.module.js +48 -0
- package/dist/angular/fturex.service.d.ts +31 -0
- package/dist/angular/fturex.service.js +56 -0
- package/dist/angular/index.d.ts +6 -0
- package/dist/angular/index.js +5 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/opentelemetry/FtureXOtelHook.d.ts +58 -0
- package/dist/opentelemetry/FtureXOtelHook.js +86 -0
- package/dist/opentelemetry/index.d.ts +2 -0
- package/dist/opentelemetry/index.js +1 -0
- package/dist/react/FeatureToggle.d.ts +14 -0
- package/dist/react/FeatureToggle.js +16 -0
- package/dist/react/FeatureToggleProvider.d.ts +15 -0
- package/dist/react/FeatureToggleProvider.js +17 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.js +3 -0
- package/dist/react/useFeatureToggle.d.ts +27 -0
- package/dist/react/useFeatureToggle.js +104 -0
- package/dist/svelte/index.d.ts +2 -0
- package/dist/svelte/index.js +1 -0
- package/dist/svelte/useFeatureToggle.d.ts +54 -0
- package/dist/svelte/useFeatureToggle.js +85 -0
- package/dist/types.d.ts +122 -0
- package/dist/types.js +1 -0
- package/dist/vue/index.d.ts +1 -0
- package/dist/vue/index.js +2 -0
- package/dist/vue/useFeatureToggle.d.ts +32 -0
- package/dist/vue/useFeatureToggle.js +104 -0
- package/package.json +99 -0
- package/src/vue/FeatureToggle.vue +28 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FtureX Client
|
|
3
|
+
* Fetches the full feature manifest in the background and evaluates
|
|
4
|
+
* conditions locally — no per-call network round-trips.
|
|
5
|
+
*/
|
|
6
|
+
export class FtureXClient {
|
|
7
|
+
constructor(config, cacheOptions = {}) {
|
|
8
|
+
// Hooks
|
|
9
|
+
this.hooks = [];
|
|
10
|
+
// Manifest replaced atomically on each background refresh (JS is single-threaded)
|
|
11
|
+
this.manifest = new Map();
|
|
12
|
+
// Event listeners
|
|
13
|
+
this.listeners = new Map([
|
|
14
|
+
["update", new Set()],
|
|
15
|
+
["ready", new Set()],
|
|
16
|
+
]);
|
|
17
|
+
// Statistics tracking
|
|
18
|
+
this.featureHitCounts = new Map();
|
|
19
|
+
this.featureEnabledCounts = new Map();
|
|
20
|
+
this.featureDisabledCounts = new Map();
|
|
21
|
+
this.featureLastAccessed = new Map();
|
|
22
|
+
this.config = {
|
|
23
|
+
baseUrl: config.baseUrl,
|
|
24
|
+
appKey: config.appKey,
|
|
25
|
+
sendStatistics: config.sendStatistics ?? true,
|
|
26
|
+
};
|
|
27
|
+
this.cacheOptions = {
|
|
28
|
+
refreshIntervalSeconds: cacheOptions.refreshIntervalSeconds ?? 30,
|
|
29
|
+
enableLocalStoragePersistence: cacheOptions.enableLocalStoragePersistence ?? true,
|
|
30
|
+
localStorageKey: cacheOptions.localStorageKey ?? "feature-toggle-cache",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Subscribe to an event.
|
|
35
|
+
* - 'ready' — fires once after the first successful manifest fetch
|
|
36
|
+
* - 'update' — fires after every subsequent manifest refresh
|
|
37
|
+
*/
|
|
38
|
+
on(event, callback) {
|
|
39
|
+
this.listeners.get(event).add(callback);
|
|
40
|
+
}
|
|
41
|
+
/** Unsubscribe a previously registered callback. */
|
|
42
|
+
off(event, callback) {
|
|
43
|
+
this.listeners.get(event).delete(callback);
|
|
44
|
+
}
|
|
45
|
+
emit(event) {
|
|
46
|
+
this.listeners.get(event).forEach((cb) => cb());
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Initialize the client — loads persisted manifest and starts background services
|
|
50
|
+
*/
|
|
51
|
+
async initialize() {
|
|
52
|
+
await this.loadManifestFromStorage();
|
|
53
|
+
await this.refreshManifest(); // initial fetch
|
|
54
|
+
this.emit("ready");
|
|
55
|
+
this.startBackgroundRefresh();
|
|
56
|
+
if (this.config.sendStatistics) {
|
|
57
|
+
this.startStatisticsReporting();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Stop all background services
|
|
62
|
+
*/
|
|
63
|
+
dispose() {
|
|
64
|
+
if (this.refreshIntervalId)
|
|
65
|
+
clearInterval(this.refreshIntervalId);
|
|
66
|
+
if (this.statisticsIntervalId)
|
|
67
|
+
clearInterval(this.statisticsIntervalId);
|
|
68
|
+
this.listeners.get("update").clear();
|
|
69
|
+
this.listeners.get("ready").clear();
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Register a hook that will be called for every flag evaluation.
|
|
73
|
+
*/
|
|
74
|
+
addHook(hook) {
|
|
75
|
+
this.hooks.push(hook);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Check if a feature is enabled (no context)
|
|
79
|
+
*/
|
|
80
|
+
async isEnabled(featureName) {
|
|
81
|
+
return this.isEnabledWithContext(featureName, {});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Check if a feature is enabled with context properties evaluated locally
|
|
85
|
+
*/
|
|
86
|
+
async isEnabledWithContext(featureName, context) {
|
|
87
|
+
const hookCtx = {
|
|
88
|
+
flagKey: featureName,
|
|
89
|
+
evaluationContext: Object.keys(context).length > 0 ? context : undefined,
|
|
90
|
+
defaultValue: false,
|
|
91
|
+
};
|
|
92
|
+
await this.invokeHooksBefore(hookCtx);
|
|
93
|
+
const entry = this.manifest.get(featureName.toLowerCase()) ??
|
|
94
|
+
this.findEntry(featureName);
|
|
95
|
+
if (!entry) {
|
|
96
|
+
const defaultResult = {
|
|
97
|
+
value: false,
|
|
98
|
+
reason: "default",
|
|
99
|
+
};
|
|
100
|
+
await this.invokeHooksAfter(hookCtx, defaultResult);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const result = this.evaluateEntry(entry, context);
|
|
104
|
+
const reason = this.determineReason(entry, context, result);
|
|
105
|
+
const evalResult = { value: result, reason };
|
|
106
|
+
await this.invokeHooksAfter(hookCtx, evalResult);
|
|
107
|
+
this.trackFeatureHit(featureName, result);
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
determineReason(entry, context, _result) {
|
|
111
|
+
if (!entry.enabled)
|
|
112
|
+
return "disabled";
|
|
113
|
+
if (!entry.conditions || entry.conditions.length === 0)
|
|
114
|
+
return "static";
|
|
115
|
+
return Object.keys(context).length > 0 ? "targeting_match" : "static";
|
|
116
|
+
}
|
|
117
|
+
async invokeHooksBefore(context) {
|
|
118
|
+
for (const hook of this.hooks) {
|
|
119
|
+
try {
|
|
120
|
+
await hook.before?.(context);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* hook errors are swallowed */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async invokeHooksAfter(context, result) {
|
|
128
|
+
for (const hook of this.hooks) {
|
|
129
|
+
try {
|
|
130
|
+
await hook.after?.(context, result);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
/* hook errors are swallowed */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async invokeHooksError(context, error) {
|
|
138
|
+
for (const hook of this.hooks) {
|
|
139
|
+
try {
|
|
140
|
+
await hook.error?.(context, error);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
/* hook errors are swallowed */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// ─── Local evaluation ───────────────────────────────────────────────────────
|
|
148
|
+
findEntry(featureName) {
|
|
149
|
+
for (const [key, value] of this.manifest.entries()) {
|
|
150
|
+
if (key.toLowerCase() === featureName.toLowerCase())
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
evaluateEntry(entry, context) {
|
|
156
|
+
if (!entry.enabled)
|
|
157
|
+
return false;
|
|
158
|
+
if (!entry.conditions || entry.conditions.length === 0)
|
|
159
|
+
return true;
|
|
160
|
+
return this.evaluateConditions(entry.conditions, context);
|
|
161
|
+
}
|
|
162
|
+
evaluateConditions(conditions, context) {
|
|
163
|
+
if (conditions.length === 0)
|
|
164
|
+
return true;
|
|
165
|
+
// Split the flat list into AND-segments at every OR boundary.
|
|
166
|
+
// AND binds tighter than OR — (A AND B OR C) = (A AND B) OR (C).
|
|
167
|
+
// The overall expression is true if ANY segment is fully satisfied.
|
|
168
|
+
const segments = [];
|
|
169
|
+
let current = [];
|
|
170
|
+
for (let i = 0; i < conditions.length; i++) {
|
|
171
|
+
current.push(conditions[i]);
|
|
172
|
+
const logic = (conditions[i].logicAfter ?? "AND").toUpperCase();
|
|
173
|
+
if (logic === "OR" || i === conditions.length - 1) {
|
|
174
|
+
segments.push(current);
|
|
175
|
+
current = [];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return segments.some((seg) => this.evaluateAndSegment(seg, context));
|
|
179
|
+
}
|
|
180
|
+
/** Returns true only when ALL rules in the segment match. */
|
|
181
|
+
evaluateAndSegment(rules, context) {
|
|
182
|
+
for (const rule of rules) {
|
|
183
|
+
const providedValue = context[rule.contextProperty];
|
|
184
|
+
if (providedValue === undefined)
|
|
185
|
+
return false;
|
|
186
|
+
let match;
|
|
187
|
+
if (rule.conditionGroupValues && rule.conditionGroupValues.length > 0) {
|
|
188
|
+
match = this.evaluateValueListCondition(rule.operator, rule.conditionGroupValues, providedValue);
|
|
189
|
+
}
|
|
190
|
+
else if (rule.value !== undefined && rule.value !== null) {
|
|
191
|
+
match = this.evaluateDirectValueCondition(rule.operator, rule.value, providedValue);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
return false; // invalid rule
|
|
195
|
+
}
|
|
196
|
+
if (!match)
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
evaluateDirectValueCondition(op, ruleValue, providedValue) {
|
|
202
|
+
switch (op) {
|
|
203
|
+
case "Contains": {
|
|
204
|
+
const ruleValues = ruleValue
|
|
205
|
+
.split(",")
|
|
206
|
+
.map((v) => v.trim().toLowerCase());
|
|
207
|
+
return providedValue
|
|
208
|
+
.split(",")
|
|
209
|
+
.some((v) => ruleValues.includes(v.trim().toLowerCase()));
|
|
210
|
+
}
|
|
211
|
+
case "NotContains": {
|
|
212
|
+
const ruleValues = ruleValue
|
|
213
|
+
.split(",")
|
|
214
|
+
.map((v) => v.trim().toLowerCase());
|
|
215
|
+
return !providedValue
|
|
216
|
+
.split(",")
|
|
217
|
+
.some((v) => ruleValues.includes(v.trim().toLowerCase()));
|
|
218
|
+
}
|
|
219
|
+
case "Match":
|
|
220
|
+
return (this.orderCommaSeparated(ruleValue) ===
|
|
221
|
+
this.orderCommaSeparated(providedValue));
|
|
222
|
+
case "NotMatch":
|
|
223
|
+
return (this.orderCommaSeparated(ruleValue) !==
|
|
224
|
+
this.orderCommaSeparated(providedValue));
|
|
225
|
+
case "GreaterThan": {
|
|
226
|
+
const rv = parseInt(ruleValue, 10);
|
|
227
|
+
const pv = parseInt(providedValue, 10);
|
|
228
|
+
return !isNaN(rv) && !isNaN(pv) && pv > rv;
|
|
229
|
+
}
|
|
230
|
+
case "LessThan": {
|
|
231
|
+
const rv = parseInt(ruleValue, 10);
|
|
232
|
+
const pv = parseInt(providedValue, 10);
|
|
233
|
+
return !isNaN(rv) && !isNaN(pv) && pv < rv;
|
|
234
|
+
}
|
|
235
|
+
default:
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
evaluateValueListCondition(op, values, providedValue) {
|
|
240
|
+
const normalised = values.map((v) => v.toLowerCase());
|
|
241
|
+
switch (op) {
|
|
242
|
+
case "Contains":
|
|
243
|
+
return providedValue
|
|
244
|
+
.split(",")
|
|
245
|
+
.some((v) => normalised.includes(v.trim().toLowerCase()));
|
|
246
|
+
case "NotContains":
|
|
247
|
+
return !providedValue
|
|
248
|
+
.split(",")
|
|
249
|
+
.some((v) => normalised.includes(v.trim().toLowerCase()));
|
|
250
|
+
case "Match":
|
|
251
|
+
return ([...values]
|
|
252
|
+
.sort((a, b) => a.localeCompare(b))
|
|
253
|
+
.join(",")
|
|
254
|
+
.toLowerCase() === this.orderCommaSeparated(providedValue));
|
|
255
|
+
case "NotMatch":
|
|
256
|
+
return ([...values]
|
|
257
|
+
.sort((a, b) => a.localeCompare(b))
|
|
258
|
+
.join(",")
|
|
259
|
+
.toLowerCase() !== this.orderCommaSeparated(providedValue));
|
|
260
|
+
default:
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
orderCommaSeparated(value) {
|
|
265
|
+
return value
|
|
266
|
+
.split(",")
|
|
267
|
+
.map((v) => v.trim().toLowerCase())
|
|
268
|
+
.sort()
|
|
269
|
+
.join(",");
|
|
270
|
+
}
|
|
271
|
+
// ─── Manifest management ────────────────────────────────────────────────────
|
|
272
|
+
async refreshManifest() {
|
|
273
|
+
try {
|
|
274
|
+
const manifest = await this.fetchManifestFromApi();
|
|
275
|
+
// Atomic swap — replace entire map reference
|
|
276
|
+
const newMap = new Map();
|
|
277
|
+
for (const entry of manifest.features) {
|
|
278
|
+
newMap.set(entry.name.toLowerCase(), entry);
|
|
279
|
+
}
|
|
280
|
+
this.manifest = newMap;
|
|
281
|
+
if (this.cacheOptions.enableLocalStoragePersistence) {
|
|
282
|
+
this.saveManifestToStorage(manifest);
|
|
283
|
+
}
|
|
284
|
+
this.emit("update");
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
console.error("Failed to refresh feature manifest:", error);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async fetchManifestFromApi() {
|
|
291
|
+
const url = `${this.config.baseUrl}/feature/manifest`;
|
|
292
|
+
const response = await fetch(url, {
|
|
293
|
+
method: "GET",
|
|
294
|
+
headers: {
|
|
295
|
+
"Content-Type": "application/json",
|
|
296
|
+
"X-API-Key": this.config.appKey,
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
if (!response.ok) {
|
|
300
|
+
throw new Error(`Manifest fetch returned ${response.status}: ${response.statusText}`);
|
|
301
|
+
}
|
|
302
|
+
return response.json();
|
|
303
|
+
}
|
|
304
|
+
// ─── Background services ────────────────────────────────────────────────────
|
|
305
|
+
startBackgroundRefresh() {
|
|
306
|
+
const intervalMs = this.cacheOptions.refreshIntervalSeconds * 1000;
|
|
307
|
+
this.refreshIntervalId = setInterval(async () => {
|
|
308
|
+
await this.refreshManifest();
|
|
309
|
+
}, intervalMs);
|
|
310
|
+
}
|
|
311
|
+
startStatisticsReporting() {
|
|
312
|
+
// Report statistics at the same interval as manifest refresh
|
|
313
|
+
const intervalMs = this.cacheOptions.refreshIntervalSeconds * 1000;
|
|
314
|
+
this.statisticsIntervalId = setInterval(async () => {
|
|
315
|
+
await this.sendStatistics();
|
|
316
|
+
}, intervalMs);
|
|
317
|
+
}
|
|
318
|
+
// ─── Statistics ─────────────────────────────────────────────────────────────
|
|
319
|
+
trackFeatureHit(featureName, wasEnabled) {
|
|
320
|
+
if (!this.config.sendStatistics)
|
|
321
|
+
return;
|
|
322
|
+
this.featureHitCounts.set(featureName, (this.featureHitCounts.get(featureName) || 0) + 1);
|
|
323
|
+
if (wasEnabled) {
|
|
324
|
+
this.featureEnabledCounts.set(featureName, (this.featureEnabledCounts.get(featureName) || 0) + 1);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
this.featureDisabledCounts.set(featureName, (this.featureDisabledCounts.get(featureName) || 0) + 1);
|
|
328
|
+
}
|
|
329
|
+
this.featureLastAccessed.set(featureName, new Date());
|
|
330
|
+
}
|
|
331
|
+
async sendStatistics() {
|
|
332
|
+
if (!this.config.sendStatistics)
|
|
333
|
+
return;
|
|
334
|
+
if (this.featureHitCounts.size === 0)
|
|
335
|
+
return;
|
|
336
|
+
try {
|
|
337
|
+
const features = [];
|
|
338
|
+
for (const [featureName, hitCount] of this.featureHitCounts.entries()) {
|
|
339
|
+
features.push({
|
|
340
|
+
featureName,
|
|
341
|
+
hitCount,
|
|
342
|
+
enabledCount: this.featureEnabledCounts.get(featureName) || 0,
|
|
343
|
+
disabledCount: this.featureDisabledCounts.get(featureName) || 0,
|
|
344
|
+
lastAccessed: this.featureLastAccessed.get(featureName) || new Date(),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
const report = {
|
|
348
|
+
appKey: this.config.appKey,
|
|
349
|
+
features,
|
|
350
|
+
reportTimestamp: new Date(),
|
|
351
|
+
};
|
|
352
|
+
const response = await fetch(`${this.config.baseUrl}/api/Statistics/Report`, {
|
|
353
|
+
method: "POST",
|
|
354
|
+
headers: {
|
|
355
|
+
"Content-Type": "application/json",
|
|
356
|
+
"X-API-Key": this.config.appKey,
|
|
357
|
+
},
|
|
358
|
+
body: JSON.stringify(report),
|
|
359
|
+
});
|
|
360
|
+
if (response.ok) {
|
|
361
|
+
this.resetStatistics();
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
console.warn(`Failed to send statistics: ${response.status}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
console.error("Error sending statistics:", error);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
resetStatistics() {
|
|
372
|
+
this.featureHitCounts.clear();
|
|
373
|
+
this.featureEnabledCounts.clear();
|
|
374
|
+
this.featureDisabledCounts.clear();
|
|
375
|
+
this.featureLastAccessed.clear();
|
|
376
|
+
}
|
|
377
|
+
getStatistics() {
|
|
378
|
+
const stats = [];
|
|
379
|
+
for (const [featureName, hitCount] of this.featureHitCounts.entries()) {
|
|
380
|
+
stats.push({
|
|
381
|
+
featureName,
|
|
382
|
+
hitCount,
|
|
383
|
+
enabledCount: this.featureEnabledCounts.get(featureName) || 0,
|
|
384
|
+
disabledCount: this.featureDisabledCounts.get(featureName) || 0,
|
|
385
|
+
lastAccessed: this.featureLastAccessed.get(featureName) || new Date(),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
return stats;
|
|
389
|
+
}
|
|
390
|
+
// ─── LocalStorage persistence ───────────────────────────────────────────────
|
|
391
|
+
saveManifestToStorage(manifest) {
|
|
392
|
+
if (typeof window === "undefined" || !window.localStorage)
|
|
393
|
+
return;
|
|
394
|
+
try {
|
|
395
|
+
localStorage.setItem(this.cacheOptions.localStorageKey, JSON.stringify(manifest));
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
console.error("Failed to persist manifest to localStorage:", error);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async loadManifestFromStorage() {
|
|
402
|
+
if (typeof window === "undefined" || !window.localStorage)
|
|
403
|
+
return;
|
|
404
|
+
try {
|
|
405
|
+
const stored = localStorage.getItem(this.cacheOptions.localStorageKey);
|
|
406
|
+
if (!stored)
|
|
407
|
+
return;
|
|
408
|
+
const manifest = JSON.parse(stored);
|
|
409
|
+
if (!manifest?.features?.length)
|
|
410
|
+
return;
|
|
411
|
+
const newMap = new Map();
|
|
412
|
+
for (const entry of manifest.features) {
|
|
413
|
+
newMap.set(entry.name.toLowerCase(), entry);
|
|
414
|
+
}
|
|
415
|
+
this.manifest = newMap;
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
console.error("Failed to load manifest from localStorage:", error);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
clearCache() {
|
|
422
|
+
this.manifest.clear();
|
|
423
|
+
if (typeof window !== "undefined" && window.localStorage) {
|
|
424
|
+
localStorage.removeItem(this.cacheOptions.localStorageKey);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { OnInit, OnDestroy, TemplateRef, ViewContainerRef } from '@angular/core';
|
|
2
|
+
import { FtureXService } from './fturex.service.js';
|
|
3
|
+
import { ContextProperties } from '../types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Structural directive that conditionally renders a template based on a feature toggle.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* <!-- Simple usage -->
|
|
9
|
+
* <div *ftureX="'new-dashboard'">New dashboard content</div>
|
|
10
|
+
*
|
|
11
|
+
* <!-- With else template -->
|
|
12
|
+
* <div *ftureX="'new-dashboard'; else legacyTpl">New content</div>
|
|
13
|
+
* <ng-template #legacyTpl>Legacy content</ng-template>
|
|
14
|
+
*
|
|
15
|
+
* <!-- With context and fallback -->
|
|
16
|
+
* <div *ftureX="'beta-feature'; context: { role: 'admin' }; fallback: false">
|
|
17
|
+
* Beta content
|
|
18
|
+
* </div>
|
|
19
|
+
*/
|
|
20
|
+
export declare class FeatureToggleDirective implements OnInit, OnDestroy {
|
|
21
|
+
private readonly templateRef;
|
|
22
|
+
private readonly viewContainer;
|
|
23
|
+
private readonly ftureXService;
|
|
24
|
+
featureName: string;
|
|
25
|
+
context?: ContextProperties;
|
|
26
|
+
elseTemplate?: TemplateRef<unknown>;
|
|
27
|
+
private subscription?;
|
|
28
|
+
private hasView;
|
|
29
|
+
constructor(templateRef: TemplateRef<unknown>, viewContainer: ViewContainerRef, ftureXService: FtureXService);
|
|
30
|
+
ngOnInit(): void;
|
|
31
|
+
ngOnDestroy(): void;
|
|
32
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
|
|
11
|
+
import { FtureXService } from './fturex.service.js';
|
|
12
|
+
/**
|
|
13
|
+
* Structural directive that conditionally renders a template based on a feature toggle.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* <!-- Simple usage -->
|
|
17
|
+
* <div *ftureX="'new-dashboard'">New dashboard content</div>
|
|
18
|
+
*
|
|
19
|
+
* <!-- With else template -->
|
|
20
|
+
* <div *ftureX="'new-dashboard'; else legacyTpl">New content</div>
|
|
21
|
+
* <ng-template #legacyTpl>Legacy content</ng-template>
|
|
22
|
+
*
|
|
23
|
+
* <!-- With context and fallback -->
|
|
24
|
+
* <div *ftureX="'beta-feature'; context: { role: 'admin' }; fallback: false">
|
|
25
|
+
* Beta content
|
|
26
|
+
* </div>
|
|
27
|
+
*/
|
|
28
|
+
let FeatureToggleDirective = class FeatureToggleDirective {
|
|
29
|
+
constructor(templateRef, viewContainer, ftureXService) {
|
|
30
|
+
this.templateRef = templateRef;
|
|
31
|
+
this.viewContainer = viewContainer;
|
|
32
|
+
this.ftureXService = ftureXService;
|
|
33
|
+
this.featureName = '';
|
|
34
|
+
this.hasView = false;
|
|
35
|
+
}
|
|
36
|
+
ngOnInit() {
|
|
37
|
+
const obs$ = this.context
|
|
38
|
+
? this.ftureXService.isEnabledWithContext(this.featureName, this.context)
|
|
39
|
+
: this.ftureXService.isEnabled(this.featureName);
|
|
40
|
+
this.subscription = obs$.subscribe((isEnabled) => {
|
|
41
|
+
if (isEnabled && !this.hasView) {
|
|
42
|
+
this.viewContainer.clear();
|
|
43
|
+
this.viewContainer.createEmbeddedView(this.templateRef);
|
|
44
|
+
this.hasView = true;
|
|
45
|
+
}
|
|
46
|
+
else if (!isEnabled) {
|
|
47
|
+
this.viewContainer.clear();
|
|
48
|
+
this.hasView = false;
|
|
49
|
+
if (this.elseTemplate) {
|
|
50
|
+
this.viewContainer.createEmbeddedView(this.elseTemplate);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
ngOnDestroy() {
|
|
56
|
+
this.subscription?.unsubscribe();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
__decorate([
|
|
60
|
+
Input('ftureX'),
|
|
61
|
+
__metadata("design:type", Object)
|
|
62
|
+
], FeatureToggleDirective.prototype, "featureName", void 0);
|
|
63
|
+
__decorate([
|
|
64
|
+
Input('ftureXContext'),
|
|
65
|
+
__metadata("design:type", Object)
|
|
66
|
+
], FeatureToggleDirective.prototype, "context", void 0);
|
|
67
|
+
__decorate([
|
|
68
|
+
Input('ftureXElse'),
|
|
69
|
+
__metadata("design:type", TemplateRef)
|
|
70
|
+
], FeatureToggleDirective.prototype, "elseTemplate", void 0);
|
|
71
|
+
FeatureToggleDirective = __decorate([
|
|
72
|
+
Directive({ selector: '[ftureX]' }),
|
|
73
|
+
__metadata("design:paramtypes", [TemplateRef,
|
|
74
|
+
ViewContainerRef,
|
|
75
|
+
FtureXService])
|
|
76
|
+
], FeatureToggleDirective);
|
|
77
|
+
export { FeatureToggleDirective };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { PipeTransform } from '@angular/core';
|
|
2
|
+
import { Observable } from 'rxjs';
|
|
3
|
+
import { FtureXService } from './fturex.service.js';
|
|
4
|
+
import { ContextProperties } from '../types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Async pipe that resolves to a boolean indicating whether a feature is enabled.
|
|
7
|
+
* Must be used with the async pipe in templates.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* <!-- In template -->
|
|
11
|
+
* <div *ngIf="'new-dashboard' | ftureX | async">New dashboard</div>
|
|
12
|
+
*
|
|
13
|
+
* <!-- With context -->
|
|
14
|
+
* <div *ngIf="'beta-feature' | ftureX: { role: 'admin' } | async">Beta</div>
|
|
15
|
+
*/
|
|
16
|
+
export declare class FeatureTogglePipe implements PipeTransform {
|
|
17
|
+
private readonly ftureXService;
|
|
18
|
+
constructor(ftureXService: FtureXService);
|
|
19
|
+
transform(featureName: string, context?: ContextProperties): Observable<boolean>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
import { Pipe } from '@angular/core';
|
|
11
|
+
import { FtureXService } from './fturex.service.js';
|
|
12
|
+
/**
|
|
13
|
+
* Async pipe that resolves to a boolean indicating whether a feature is enabled.
|
|
14
|
+
* Must be used with the async pipe in templates.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* <!-- In template -->
|
|
18
|
+
* <div *ngIf="'new-dashboard' | ftureX | async">New dashboard</div>
|
|
19
|
+
*
|
|
20
|
+
* <!-- With context -->
|
|
21
|
+
* <div *ngIf="'beta-feature' | ftureX: { role: 'admin' } | async">Beta</div>
|
|
22
|
+
*/
|
|
23
|
+
let FeatureTogglePipe = class FeatureTogglePipe {
|
|
24
|
+
constructor(ftureXService) {
|
|
25
|
+
this.ftureXService = ftureXService;
|
|
26
|
+
}
|
|
27
|
+
transform(featureName, context) {
|
|
28
|
+
return context
|
|
29
|
+
? this.ftureXService.isEnabledWithContext(featureName, context)
|
|
30
|
+
: this.ftureXService.isEnabled(featureName);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
FeatureTogglePipe = __decorate([
|
|
34
|
+
Pipe({ name: 'ftureX', pure: true }),
|
|
35
|
+
__metadata("design:paramtypes", [FtureXService])
|
|
36
|
+
], FeatureTogglePipe);
|
|
37
|
+
export { FeatureTogglePipe };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { InjectionToken } from '@angular/core';
|
|
2
|
+
import { FtureXConfiguration, FeatureCacheOptions } from '../types.js';
|
|
3
|
+
export interface FtureXModuleConfig {
|
|
4
|
+
config: FtureXConfiguration;
|
|
5
|
+
cacheOptions?: FeatureCacheOptions;
|
|
6
|
+
}
|
|
7
|
+
export declare const FTUREX_CONFIG: InjectionToken<FtureXModuleConfig>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ModuleWithProviders } from '@angular/core';
|
|
2
|
+
import { FtureXModuleConfig } from './fturex.config.js';
|
|
3
|
+
/**
|
|
4
|
+
* Angular module for FtureX feature toggles.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* // app.module.ts
|
|
8
|
+
* @NgModule({
|
|
9
|
+
* imports: [
|
|
10
|
+
* FtureXModule.forRoot({
|
|
11
|
+
* config: {
|
|
12
|
+
* baseUrl: 'https://api.example.com',
|
|
13
|
+
* appKey: 'your-api-key',
|
|
14
|
+
* },
|
|
15
|
+
* cacheOptions: { refreshIntervalSeconds: 60 },
|
|
16
|
+
* }),
|
|
17
|
+
* ],
|
|
18
|
+
* })
|
|
19
|
+
* export class AppModule {}
|
|
20
|
+
*/
|
|
21
|
+
export declare class FtureXModule {
|
|
22
|
+
static forRoot(moduleConfig: FtureXModuleConfig): ModuleWithProviders<FtureXModule>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var FtureXModule_1;
|
|
8
|
+
import { NgModule } from '@angular/core';
|
|
9
|
+
import { FtureXService } from './fturex.service.js';
|
|
10
|
+
import { FeatureToggleDirective } from './feature-toggle.directive.js';
|
|
11
|
+
import { FeatureTogglePipe } from './feature-toggle.pipe.js';
|
|
12
|
+
import { FTUREX_CONFIG } from './fturex.config.js';
|
|
13
|
+
/**
|
|
14
|
+
* Angular module for FtureX feature toggles.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* // app.module.ts
|
|
18
|
+
* @NgModule({
|
|
19
|
+
* imports: [
|
|
20
|
+
* FtureXModule.forRoot({
|
|
21
|
+
* config: {
|
|
22
|
+
* baseUrl: 'https://api.example.com',
|
|
23
|
+
* appKey: 'your-api-key',
|
|
24
|
+
* },
|
|
25
|
+
* cacheOptions: { refreshIntervalSeconds: 60 },
|
|
26
|
+
* }),
|
|
27
|
+
* ],
|
|
28
|
+
* })
|
|
29
|
+
* export class AppModule {}
|
|
30
|
+
*/
|
|
31
|
+
let FtureXModule = FtureXModule_1 = class FtureXModule {
|
|
32
|
+
static forRoot(moduleConfig) {
|
|
33
|
+
return {
|
|
34
|
+
ngModule: FtureXModule_1,
|
|
35
|
+
providers: [
|
|
36
|
+
{ provide: FTUREX_CONFIG, useValue: moduleConfig },
|
|
37
|
+
FtureXService,
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
FtureXModule = FtureXModule_1 = __decorate([
|
|
43
|
+
NgModule({
|
|
44
|
+
declarations: [FeatureToggleDirective, FeatureTogglePipe],
|
|
45
|
+
exports: [FeatureToggleDirective, FeatureTogglePipe],
|
|
46
|
+
})
|
|
47
|
+
], FtureXModule);
|
|
48
|
+
export { FtureXModule };
|