@tehw0lf/yaft 0.0.15 → 0.0.16
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/FeatureToggle.d.ts +20 -0
- package/FeatureToggle.js +72 -0
- package/README.md +10 -1
- package/evaluate.d.ts +42 -0
- package/evaluate.js +102 -0
- package/examples/ApiServiceBooleanProvider.d.ts +11 -0
- package/examples/ApiServiceBooleanProvider.js +82 -0
- package/examples/ApiServiceFeatureProvider.d.ts +17 -0
- package/examples/ApiServiceFeatureProvider.js +54 -0
- package/examples/LocalStorageBooleanProvider.d.ts +7 -0
- package/examples/LocalStorageBooleanProvider.js +26 -0
- package/examples/LocalStorageFeatureProvider.d.ts +14 -0
- package/examples/LocalStorageFeatureProvider.js +31 -0
- package/{src/index.ts → index.d.ts} +1 -6
- package/index.js +13 -0
- package/mapping.d.ts +27 -0
- package/mapping.js +79 -0
- package/package.json +21 -9
- package/.github/workflows/build.yml +0 -28
- package/.github/workflows/security-scan.yml +0 -79
- package/CLAUDE.md +0 -151
- package/LICENSE +0 -21
- package/conformance.lock +0 -2
- package/jest.config.js +0 -16
- package/scripts/fetch-conformance.sh +0 -67
- package/src/FeatureToggle.ts +0 -95
- package/src/evaluate.ts +0 -122
- package/src/examples/ApiServiceBooleanProvider.ts +0 -82
- package/src/examples/ApiServiceFeatureProvider.ts +0 -52
- package/src/examples/LocalStorageBooleanProvider.ts +0 -25
- package/src/examples/LocalStorageFeatureProvider.ts +0 -31
- package/src/mapping.ts +0 -92
- package/src/test/conformance-adapter/cases.ts +0 -104
- package/src/test/conformance-adapter/decorator.spec.ts +0 -376
- package/src/test/conformance-adapter/evaluation.spec.ts +0 -31
- package/src/test/conformance-adapter/mapping.spec.ts +0 -79
- package/src/test/decorator-behavior.spec.ts +0 -359
- package/src/test/error-handling.spec.ts +0 -396
- package/src/test/evaluate.spec.ts +0 -260
- package/src/test/feature-data.spec.ts +0 -102
- package/src/test/injectable-clock.spec.ts +0 -112
- package/src/test/integration.spec.ts +0 -472
- package/src/test/providers.spec.ts +0 -218
- package/src/test/test-boolean.json +0 -1
- package/src/test/test-feature.json +0 -20
- package/src/test/test-setup.ts +0 -2
- package/src/test/time-based-logic.spec.ts +0 -169
- package/src/test/yaft.demo.spec.ts +0 -53
- package/tsconfig.json +0 -14
- package/tsconfig.spec.json +0 -10
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
export type Feature = {
|
|
3
|
+
key: string;
|
|
4
|
+
value: string;
|
|
5
|
+
activeAt: string;
|
|
6
|
+
disabledAt: string;
|
|
7
|
+
tags?: string[];
|
|
8
|
+
};
|
|
9
|
+
export interface FeatureProvider<T> {
|
|
10
|
+
apiUrl?: string;
|
|
11
|
+
baseUUID?: string;
|
|
12
|
+
data: Record<string, T>;
|
|
13
|
+
getCollectionHash?(configPathOrUrl: string): void;
|
|
14
|
+
getConfig(configPathOrUrl: string): void;
|
|
15
|
+
isEnabled(key: string): boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare function FeatureToggle(key: string, fallback?: any): (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
|
|
18
|
+
export declare abstract class FeatureToggleBase {
|
|
19
|
+
static featureProvider: FeatureProvider<any>;
|
|
20
|
+
}
|
package/FeatureToggle.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FeatureToggleBase = void 0;
|
|
4
|
+
exports.FeatureToggle = FeatureToggle;
|
|
5
|
+
require("reflect-metadata");
|
|
6
|
+
class EmptyClass {
|
|
7
|
+
constructor() { }
|
|
8
|
+
}
|
|
9
|
+
function FeatureToggle(key, fallback) {
|
|
10
|
+
if (!FeatureToggleBase.featureProvider) {
|
|
11
|
+
throw new Error("FeatureToggleProvider not set");
|
|
12
|
+
}
|
|
13
|
+
return (target, propertyKey, descriptor) => {
|
|
14
|
+
if (propertyKey && descriptor) {
|
|
15
|
+
// Method
|
|
16
|
+
const originalMethod = descriptor.value;
|
|
17
|
+
// An async method must keep returning a promise when it is switched
|
|
18
|
+
// off, or `await` at the call site breaks on a plain undefined. The
|
|
19
|
+
// empty class shell below already makes this distinction; without it
|
|
20
|
+
// here, turning a feature off would throw inside unrelated code.
|
|
21
|
+
const isAsync = originalMethod?.[Symbol.toStringTag] === "AsyncFunction";
|
|
22
|
+
descriptor.value = function (...args) {
|
|
23
|
+
const isEnabled = FeatureToggleBase.featureProvider.isEnabled(key);
|
|
24
|
+
if (isEnabled) {
|
|
25
|
+
return originalMethod.apply(this, args);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
if (fallback !== undefined) {
|
|
29
|
+
const result = fallback.apply(this, args);
|
|
30
|
+
// A synchronous fallback on an async method would otherwise hand
|
|
31
|
+
// back a plain value, breaking the promise the signature
|
|
32
|
+
// advertises. Promise.resolve passes an existing promise through
|
|
33
|
+
// unchanged, so an async fallback is unaffected.
|
|
34
|
+
return isAsync ? Promise.resolve(result) : result;
|
|
35
|
+
}
|
|
36
|
+
return isAsync ? Promise.resolve() : undefined;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
return descriptor;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
// Class
|
|
43
|
+
const originalConstructor = target;
|
|
44
|
+
let newConstructor;
|
|
45
|
+
const isEnabled = FeatureToggleBase.featureProvider.isEnabled(key);
|
|
46
|
+
if (isEnabled) {
|
|
47
|
+
newConstructor = originalConstructor;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
newConstructor = fallback !== undefined ? fallback : EmptyClass;
|
|
51
|
+
if (fallback)
|
|
52
|
+
newConstructor.__proto__ = fallback.__proto__;
|
|
53
|
+
if (newConstructor === EmptyClass) {
|
|
54
|
+
Object.getOwnPropertyNames(originalConstructor.prototype).forEach((name) => {
|
|
55
|
+
if (name === "constructor")
|
|
56
|
+
return;
|
|
57
|
+
if (typeof originalConstructor.prototype[name] === "function")
|
|
58
|
+
newConstructor.prototype[name] = () => { };
|
|
59
|
+
if (originalConstructor.prototype[name][Symbol.toStringTag] ===
|
|
60
|
+
"AsyncFunction")
|
|
61
|
+
newConstructor.prototype[name] = async () => { };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return newConstructor;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
class FeatureToggleBase {
|
|
70
|
+
static featureProvider;
|
|
71
|
+
}
|
|
72
|
+
exports.FeatureToggleBase = FeatureToggleBase;
|
package/README.md
CHANGED
|
@@ -12,7 +12,16 @@ This provides a client for YaFT which aims to bring simple feature toggles for M
|
|
|
12
12
|
|
|
13
13
|
## Installation
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
```bash
|
|
16
|
+
npm install @tehw0lf/yaft
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The example API providers use axios and declare it as an optional peer
|
|
20
|
+
dependency, so install it too if you use them:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install @tehw0lf/yaft axios
|
|
24
|
+
```
|
|
16
25
|
|
|
17
26
|
## Initialization
|
|
18
27
|
|
package/evaluate.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Feature } from "./FeatureToggle";
|
|
2
|
+
/**
|
|
3
|
+
* A source of the current time, in milliseconds since the epoch.
|
|
4
|
+
*
|
|
5
|
+
* Everything that evaluates a feature takes one of these instead of calling
|
|
6
|
+
* `Date.now()` directly, so tests -- and the conformance suite, which supplies
|
|
7
|
+
* a `now` with every case -- can evaluate against a fixed instant.
|
|
8
|
+
*/
|
|
9
|
+
export type Clock = () => number;
|
|
10
|
+
/** The default clock: the system time. */
|
|
11
|
+
export declare const systemClock: Clock;
|
|
12
|
+
/**
|
|
13
|
+
* Parses an RFC 3339 timestamp with an offset.
|
|
14
|
+
*
|
|
15
|
+
* Returns `undefined` for anything unset, malformed or in another format;
|
|
16
|
+
* callers treat that as "no bound", never as an error. An invalid value is
|
|
17
|
+
* warned about but never throws, so a bad timestamp in the backend cannot take
|
|
18
|
+
* an application down.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseTimestamp(value: string | null | undefined): number | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Decides whether a feature is on at the instant `now`.
|
|
23
|
+
*
|
|
24
|
+
* This is the single definition of YaFT's evaluation rules. Providers call it
|
|
25
|
+
* rather than implementing the logic themselves, so every provider -- and
|
|
26
|
+
* every port that mirrors this function -- agrees on the same answer.
|
|
27
|
+
*
|
|
28
|
+
* The rules:
|
|
29
|
+
*
|
|
30
|
+
* - A missing feature is off.
|
|
31
|
+
* - Only the exact string `"true"` is on. `"TRUE"`, `"1"` and `""` are off,
|
|
32
|
+
* because the backend stores the value as a string and anything else would
|
|
33
|
+
* be a silent disagreement between backend and client.
|
|
34
|
+
* - `activeAt` and `disabledAt` are optional bounds. Unset, null or
|
|
35
|
+
* unparseable values are ignored rather than treated as an error.
|
|
36
|
+
* - The window is half-open: at exactly `activeAt` the feature is on
|
|
37
|
+
* (`now < activeAt` is off), at exactly `disabledAt` it is off
|
|
38
|
+
* (`now >= disabledAt` is off).
|
|
39
|
+
* - `activeAt` after `disabledAt` is not special-cased; it simply yields a
|
|
40
|
+
* window that is never open.
|
|
41
|
+
*/
|
|
42
|
+
export declare function evaluate(feature: Feature | null | undefined, now: number): boolean;
|
package/evaluate.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.systemClock = void 0;
|
|
4
|
+
exports.parseTimestamp = parseTimestamp;
|
|
5
|
+
exports.evaluate = evaluate;
|
|
6
|
+
/** The default clock: the system time. */
|
|
7
|
+
const systemClock = () => Date.now();
|
|
8
|
+
exports.systemClock = systemClock;
|
|
9
|
+
/**
|
|
10
|
+
* Matches RFC 3339 timestamps that carry an explicit offset (`Z` or `±hh:mm`).
|
|
11
|
+
*
|
|
12
|
+
* Only this format is accepted. A bare date such as `2026-09-18` or a
|
|
13
|
+
* timestamp without an offset is rejected, because languages disagree on how
|
|
14
|
+
* to read them -- JavaScript treats a bare date as UTC midnight and an
|
|
15
|
+
* offset-less timestamp as local time, while most other languages read both as
|
|
16
|
+
* local. A feature would then flip at a different instant depending on which
|
|
17
|
+
* port evaluated it, so such values are ignored rather than guessed at.
|
|
18
|
+
*/
|
|
19
|
+
const RFC3339_WITH_OFFSET = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
|
|
20
|
+
/** Days per month, index 1-12; February is handled by the leap-year branch. */
|
|
21
|
+
const DAYS_IN_MONTH = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
22
|
+
function isLeapYear(year) {
|
|
23
|
+
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
24
|
+
}
|
|
25
|
+
function isRealDate(year, month, day) {
|
|
26
|
+
if (month < 1 || month > 12 || day < 1)
|
|
27
|
+
return false;
|
|
28
|
+
const max = month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month];
|
|
29
|
+
return day <= max;
|
|
30
|
+
}
|
|
31
|
+
function isRealTime(hour, minute, second) {
|
|
32
|
+
// RFC 3339 permits second 60 for a leap second; it is allowed through here
|
|
33
|
+
// and then rejected by the NaN guard, because Date.parse cannot represent
|
|
34
|
+
// one. The value ends up ignored either way.
|
|
35
|
+
return hour <= 23 && minute <= 59 && second <= 60;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parses an RFC 3339 timestamp with an offset.
|
|
39
|
+
*
|
|
40
|
+
* Returns `undefined` for anything unset, malformed or in another format;
|
|
41
|
+
* callers treat that as "no bound", never as an error. An invalid value is
|
|
42
|
+
* warned about but never throws, so a bad timestamp in the backend cannot take
|
|
43
|
+
* an application down.
|
|
44
|
+
*/
|
|
45
|
+
function parseTimestamp(value) {
|
|
46
|
+
if (value === null || value === undefined || value === "")
|
|
47
|
+
return undefined;
|
|
48
|
+
const match = RFC3339_WITH_OFFSET.exec(value);
|
|
49
|
+
if (!match) {
|
|
50
|
+
console.warn(`YaFT: ignoring "${value}", expected RFC 3339 with an offset (e.g. 2026-09-18T15:00:00Z)`);
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
// The pattern only checks the shape, and Date.parse does not reject an
|
|
54
|
+
// impossible calendar date -- it rolls it over, turning 2027-02-30 into
|
|
55
|
+
// 2027-03-02. Silently shifting a bound by days is worse than ignoring it,
|
|
56
|
+
// so the components are range-checked first.
|
|
57
|
+
const [, year, month, day, hour, minute, second] = match;
|
|
58
|
+
if (!isRealDate(+year, +month, +day) || !isRealTime(+hour, +minute, +second)) {
|
|
59
|
+
console.warn(`YaFT: ignoring "${value}", not a valid date or time`);
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
const parsed = Date.parse(value);
|
|
63
|
+
if (Number.isNaN(parsed)) {
|
|
64
|
+
console.warn(`YaFT: ignoring "${value}", not a valid timestamp`);
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
return parsed;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Decides whether a feature is on at the instant `now`.
|
|
71
|
+
*
|
|
72
|
+
* This is the single definition of YaFT's evaluation rules. Providers call it
|
|
73
|
+
* rather than implementing the logic themselves, so every provider -- and
|
|
74
|
+
* every port that mirrors this function -- agrees on the same answer.
|
|
75
|
+
*
|
|
76
|
+
* The rules:
|
|
77
|
+
*
|
|
78
|
+
* - A missing feature is off.
|
|
79
|
+
* - Only the exact string `"true"` is on. `"TRUE"`, `"1"` and `""` are off,
|
|
80
|
+
* because the backend stores the value as a string and anything else would
|
|
81
|
+
* be a silent disagreement between backend and client.
|
|
82
|
+
* - `activeAt` and `disabledAt` are optional bounds. Unset, null or
|
|
83
|
+
* unparseable values are ignored rather than treated as an error.
|
|
84
|
+
* - The window is half-open: at exactly `activeAt` the feature is on
|
|
85
|
+
* (`now < activeAt` is off), at exactly `disabledAt` it is off
|
|
86
|
+
* (`now >= disabledAt` is off).
|
|
87
|
+
* - `activeAt` after `disabledAt` is not special-cased; it simply yields a
|
|
88
|
+
* window that is never open.
|
|
89
|
+
*/
|
|
90
|
+
function evaluate(feature, now) {
|
|
91
|
+
if (feature === undefined || feature === null)
|
|
92
|
+
return false;
|
|
93
|
+
if (feature.value !== "true")
|
|
94
|
+
return false;
|
|
95
|
+
const activeAt = parseTimestamp(feature.activeAt);
|
|
96
|
+
if (activeAt !== undefined && now < activeAt)
|
|
97
|
+
return false;
|
|
98
|
+
const disabledAt = parseTimestamp(feature.disabledAt);
|
|
99
|
+
if (disabledAt !== undefined && now >= disabledAt)
|
|
100
|
+
return false;
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { FeatureProvider } from "../FeatureToggle";
|
|
2
|
+
export declare class ApiServiceBooleanProvider implements FeatureProvider<boolean> {
|
|
3
|
+
apiUrl: string;
|
|
4
|
+
baseUUID: string;
|
|
5
|
+
data: Record<string, boolean>;
|
|
6
|
+
collectionHash: string;
|
|
7
|
+
constructor(apiUrl: string, baseUUID: string);
|
|
8
|
+
getCollectionHash(configPathOrUrl: string): Promise<void>;
|
|
9
|
+
getConfig(configPathOrUrl: string): Promise<void>;
|
|
10
|
+
isEnabled(key: string): boolean;
|
|
11
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ApiServiceBooleanProvider = void 0;
|
|
7
|
+
const axios_1 = __importDefault(require("axios"));
|
|
8
|
+
const mapping_1 = require("../mapping");
|
|
9
|
+
class ApiServiceBooleanProvider {
|
|
10
|
+
apiUrl;
|
|
11
|
+
baseUUID;
|
|
12
|
+
data = {};
|
|
13
|
+
collectionHash = "";
|
|
14
|
+
constructor(apiUrl, baseUUID) {
|
|
15
|
+
this.apiUrl = apiUrl;
|
|
16
|
+
this.baseUUID = baseUUID;
|
|
17
|
+
this.getCollectionHash(`${this.apiUrl}/collectionHash/${this.baseUUID}`);
|
|
18
|
+
}
|
|
19
|
+
async getCollectionHash(configPathOrUrl) {
|
|
20
|
+
try {
|
|
21
|
+
const response = await axios_1.default.get(configPathOrUrl);
|
|
22
|
+
const newHash = response.data.collectionHash || response.data.value;
|
|
23
|
+
if (this.collectionHash !== newHash) {
|
|
24
|
+
this.collectionHash = newHash;
|
|
25
|
+
await this.getConfig(`${this.apiUrl}/features/${this.baseUUID}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
console.error("Failed to fetch feature toggle from API:", error);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async getConfig(configPathOrUrl) {
|
|
33
|
+
try {
|
|
34
|
+
const response = await axios_1.default.get(configPathOrUrl);
|
|
35
|
+
// A keyed boolean object is already in this provider's shape and is
|
|
36
|
+
// taken as-is; anything else is a feature-shaped response and goes
|
|
37
|
+
// through the core normaliser, so the two providers cannot disagree
|
|
38
|
+
// about what a response means.
|
|
39
|
+
if (isKeyedBooleans(response.data)) {
|
|
40
|
+
this.data = response.data;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Only the value matters here. The boolean shape has no time logic by
|
|
44
|
+
// design (R21), so a feature collapses to whether its value is exactly
|
|
45
|
+
// "true" -- activeAt and disabledAt are dropped.
|
|
46
|
+
//
|
|
47
|
+
// That is a real trap when this provider is pointed at a backend that
|
|
48
|
+
// schedules toggles: the window is then enforced only by the backend's
|
|
49
|
+
// cron job, which lags by up to a minute, instead of being evaluated
|
|
50
|
+
// locally. Use ApiServiceFeatureProvider when the toggles carry dates.
|
|
51
|
+
const features = (0, mapping_1.normaliseCollection)(response.data);
|
|
52
|
+
this.data = {};
|
|
53
|
+
for (const [key, feature] of Object.entries(features)) {
|
|
54
|
+
this.data[key] = feature.value === 'true';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
console.error("Failed to fetch feature toggle from API:", error);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
isEnabled(key) {
|
|
62
|
+
const feature = this.data[key];
|
|
63
|
+
if (feature === undefined || feature === null)
|
|
64
|
+
return false;
|
|
65
|
+
return feature;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
exports.ApiServiceBooleanProvider = ApiServiceBooleanProvider;
|
|
69
|
+
/**
|
|
70
|
+
* True when the payload is already `{ "myToggle": true }`.
|
|
71
|
+
*
|
|
72
|
+
* Distinguishing this from a feature-shaped response matters: running a keyed
|
|
73
|
+
* boolean object through the feature normaliser would look for a `key` field,
|
|
74
|
+
* find none and discard every entry.
|
|
75
|
+
*/
|
|
76
|
+
function isKeyedBooleans(data) {
|
|
77
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const values = Object.values(data);
|
|
81
|
+
return values.length > 0 && values.every((v) => typeof v === 'boolean');
|
|
82
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Clock } from "../evaluate";
|
|
2
|
+
import { Feature, FeatureProvider } from "../FeatureToggle";
|
|
3
|
+
export declare class ApiServiceFeatureProvider implements FeatureProvider<Feature> {
|
|
4
|
+
apiUrl: string;
|
|
5
|
+
baseUUID: string;
|
|
6
|
+
data: Record<string, Feature>;
|
|
7
|
+
collectionHash: string;
|
|
8
|
+
private readonly clock;
|
|
9
|
+
/**
|
|
10
|
+
* @param clock source of the current time; override it to evaluate against a
|
|
11
|
+
* fixed instant in tests
|
|
12
|
+
*/
|
|
13
|
+
constructor(apiUrl: string, baseUUID: string, clock?: Clock);
|
|
14
|
+
getCollectionHash(configPathOrUrl: string): Promise<void>;
|
|
15
|
+
getConfig(configPathOrUrl: string): Promise<void>;
|
|
16
|
+
isEnabled(key: string): boolean;
|
|
17
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ApiServiceFeatureProvider = void 0;
|
|
7
|
+
const axios_1 = __importDefault(require("axios"));
|
|
8
|
+
const evaluate_1 = require("../evaluate");
|
|
9
|
+
const mapping_1 = require("../mapping");
|
|
10
|
+
class ApiServiceFeatureProvider {
|
|
11
|
+
apiUrl;
|
|
12
|
+
baseUUID;
|
|
13
|
+
data = {};
|
|
14
|
+
collectionHash = "";
|
|
15
|
+
clock;
|
|
16
|
+
/**
|
|
17
|
+
* @param clock source of the current time; override it to evaluate against a
|
|
18
|
+
* fixed instant in tests
|
|
19
|
+
*/
|
|
20
|
+
constructor(apiUrl, baseUUID, clock = evaluate_1.systemClock) {
|
|
21
|
+
this.clock = clock;
|
|
22
|
+
this.apiUrl = apiUrl;
|
|
23
|
+
this.baseUUID = baseUUID;
|
|
24
|
+
this.getCollectionHash(`${this.apiUrl}/collectionHash/${this.baseUUID}`);
|
|
25
|
+
}
|
|
26
|
+
async getCollectionHash(configPathOrUrl) {
|
|
27
|
+
try {
|
|
28
|
+
const response = await axios_1.default.get(configPathOrUrl);
|
|
29
|
+
const newHash = response.data.collectionHash || response.data.value;
|
|
30
|
+
if (this.collectionHash !== newHash) {
|
|
31
|
+
this.collectionHash = newHash;
|
|
32
|
+
await this.getConfig(`${this.apiUrl}/features/${this.baseUUID}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
console.error("Failed to fetch feature toggle from API:", error);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async getConfig(configPathOrUrl) {
|
|
40
|
+
try {
|
|
41
|
+
const response = await axios_1.default.get(configPathOrUrl);
|
|
42
|
+
// The mapping rules live in the core, next to the evaluation rules,
|
|
43
|
+
// rather than being reimplemented per provider.
|
|
44
|
+
this.data = (0, mapping_1.normaliseCollection)(response.data);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
console.error("Failed to fetch feature toggle from API:", error);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
isEnabled(key) {
|
|
51
|
+
return (0, evaluate_1.evaluate)(this.data[key], this.clock());
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
exports.ApiServiceFeatureProvider = ApiServiceFeatureProvider;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { FeatureProvider } from "../FeatureToggle";
|
|
2
|
+
export declare class LocalStorageBooleanProvider implements FeatureProvider<boolean> {
|
|
3
|
+
data: Record<string, boolean>;
|
|
4
|
+
constructor(configPath: string);
|
|
5
|
+
getConfig(configPathOrUrl: string): void;
|
|
6
|
+
isEnabled(key: string): boolean;
|
|
7
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LocalStorageBooleanProvider = void 0;
|
|
4
|
+
class LocalStorageBooleanProvider {
|
|
5
|
+
data = {};
|
|
6
|
+
constructor(configPath) {
|
|
7
|
+
this.getConfig(configPath);
|
|
8
|
+
}
|
|
9
|
+
getConfig(configPathOrUrl) {
|
|
10
|
+
try {
|
|
11
|
+
const configData = require(configPathOrUrl);
|
|
12
|
+
this.data = configData;
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
console.error("Failed to load configuration from local file:", error);
|
|
16
|
+
this.data = {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
isEnabled(key) {
|
|
20
|
+
const feature = this.data[key];
|
|
21
|
+
if (feature === undefined || feature === null)
|
|
22
|
+
return false;
|
|
23
|
+
return feature;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
exports.LocalStorageBooleanProvider = LocalStorageBooleanProvider;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Clock } from "../evaluate";
|
|
2
|
+
import { Feature, FeatureProvider } from "../FeatureToggle";
|
|
3
|
+
export declare class LocalStorageFeatureProvider implements FeatureProvider<Feature> {
|
|
4
|
+
data: Record<string, Feature>;
|
|
5
|
+
private readonly clock;
|
|
6
|
+
/**
|
|
7
|
+
* @param configPath path passed to `require()`
|
|
8
|
+
* @param clock source of the current time; override it to evaluate against a
|
|
9
|
+
* fixed instant in tests
|
|
10
|
+
*/
|
|
11
|
+
constructor(configPath: string, clock?: Clock);
|
|
12
|
+
getConfig(configPathOrUrl: string): void;
|
|
13
|
+
isEnabled(key: string): boolean;
|
|
14
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LocalStorageFeatureProvider = void 0;
|
|
4
|
+
const evaluate_1 = require("../evaluate");
|
|
5
|
+
class LocalStorageFeatureProvider {
|
|
6
|
+
data = {};
|
|
7
|
+
clock;
|
|
8
|
+
/**
|
|
9
|
+
* @param configPath path passed to `require()`
|
|
10
|
+
* @param clock source of the current time; override it to evaluate against a
|
|
11
|
+
* fixed instant in tests
|
|
12
|
+
*/
|
|
13
|
+
constructor(configPath, clock = evaluate_1.systemClock) {
|
|
14
|
+
this.clock = clock;
|
|
15
|
+
this.getConfig(configPath);
|
|
16
|
+
}
|
|
17
|
+
getConfig(configPathOrUrl) {
|
|
18
|
+
try {
|
|
19
|
+
const configData = require(configPathOrUrl);
|
|
20
|
+
this.data = configData;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
console.error("Failed to load configuration from local file:", error);
|
|
24
|
+
this.data = {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
isEnabled(key) {
|
|
28
|
+
return (0, evaluate_1.evaluate)(this.data[key], this.clock());
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
exports.LocalStorageFeatureProvider = LocalStorageFeatureProvider;
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
export {
|
|
2
|
-
Feature,
|
|
3
|
-
FeatureToggle,
|
|
4
|
-
FeatureToggleBase,
|
|
5
|
-
FeatureProvider,
|
|
6
|
-
} from "./FeatureToggle";
|
|
1
|
+
export { Feature, FeatureToggle, FeatureToggleBase, FeatureProvider, } from "./FeatureToggle";
|
|
7
2
|
export { Clock, evaluate, parseTimestamp, systemClock } from "./evaluate";
|
|
8
3
|
export { normaliseCollection, normaliseFeature } from "./mapping";
|
package/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normaliseFeature = exports.normaliseCollection = exports.systemClock = exports.parseTimestamp = exports.evaluate = exports.FeatureToggleBase = exports.FeatureToggle = void 0;
|
|
4
|
+
var FeatureToggle_1 = require("./FeatureToggle");
|
|
5
|
+
Object.defineProperty(exports, "FeatureToggle", { enumerable: true, get: function () { return FeatureToggle_1.FeatureToggle; } });
|
|
6
|
+
Object.defineProperty(exports, "FeatureToggleBase", { enumerable: true, get: function () { return FeatureToggle_1.FeatureToggleBase; } });
|
|
7
|
+
var evaluate_1 = require("./evaluate");
|
|
8
|
+
Object.defineProperty(exports, "evaluate", { enumerable: true, get: function () { return evaluate_1.evaluate; } });
|
|
9
|
+
Object.defineProperty(exports, "parseTimestamp", { enumerable: true, get: function () { return evaluate_1.parseTimestamp; } });
|
|
10
|
+
Object.defineProperty(exports, "systemClock", { enumerable: true, get: function () { return evaluate_1.systemClock; } });
|
|
11
|
+
var mapping_1 = require("./mapping");
|
|
12
|
+
Object.defineProperty(exports, "normaliseCollection", { enumerable: true, get: function () { return mapping_1.normaliseCollection; } });
|
|
13
|
+
Object.defineProperty(exports, "normaliseFeature", { enumerable: true, get: function () { return mapping_1.normaliseFeature; } });
|
package/mapping.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Feature } from './FeatureToggle';
|
|
2
|
+
/**
|
|
3
|
+
* Turns a backend response into provider data.
|
|
4
|
+
*
|
|
5
|
+
* This is the single definition of YaFT's mapping rules, the way `evaluate` is
|
|
6
|
+
* the single definition of the evaluation rules. Providers call it instead of
|
|
7
|
+
* unpacking responses themselves, so every provider -- and every port that
|
|
8
|
+
* mirrors it -- agrees on what a response means.
|
|
9
|
+
*/
|
|
10
|
+
/** A raw entry as it arrives over the wire, in either field spelling. */
|
|
11
|
+
type RawFeature = Record<string, unknown>;
|
|
12
|
+
/** Normalises one entry into a `Feature`, whichever spelling it arrived in. */
|
|
13
|
+
export declare function normaliseFeature(raw: RawFeature): Feature;
|
|
14
|
+
/**
|
|
15
|
+
* Normalises a whole response into features keyed by their key.
|
|
16
|
+
*
|
|
17
|
+
* Three envelopes are accepted, because the backend uses all three:
|
|
18
|
+
*
|
|
19
|
+
* - `{ "toggles": [...] }` for a UUID group;
|
|
20
|
+
* - `{ "value": [...] }`, the same thing under a different name;
|
|
21
|
+
* - a flat object for a single toggle.
|
|
22
|
+
*
|
|
23
|
+
* An entry without a usable key is skipped rather than stored under `""`,
|
|
24
|
+
* where `isEnabled("")` could reach it.
|
|
25
|
+
*/
|
|
26
|
+
export declare function normaliseCollection(response: unknown): Record<string, Feature>;
|
|
27
|
+
export {};
|
package/mapping.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normaliseFeature = normaliseFeature;
|
|
4
|
+
exports.normaliseCollection = normaliseCollection;
|
|
5
|
+
/**
|
|
6
|
+
* Reads a field by presence, not by truthiness.
|
|
7
|
+
*
|
|
8
|
+
* The obvious `raw.value || raw.Value` is wrong: a present but empty value
|
|
9
|
+
* falls through to the other spelling, so a feature stored as `""` reads as
|
|
10
|
+
* whatever the capitalised field holds. An off feature then reports on. The
|
|
11
|
+
* same trap applies to `tags: []`.
|
|
12
|
+
*
|
|
13
|
+
* Backends from 0.2.0 on send only the lowercase spelling; the capitalised one
|
|
14
|
+
* is read because instances before that are still around.
|
|
15
|
+
*/
|
|
16
|
+
function field(raw, lower, upper) {
|
|
17
|
+
if (lower in raw)
|
|
18
|
+
return raw[lower];
|
|
19
|
+
if (upper in raw)
|
|
20
|
+
return raw[upper];
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Normalises a date field. The backend sends `null` for an unset bound and
|
|
25
|
+
* local fixtures use `""`; both mean "no bound", and `evaluate` ignores either.
|
|
26
|
+
*/
|
|
27
|
+
function date(value) {
|
|
28
|
+
return typeof value === 'string' ? value : '';
|
|
29
|
+
}
|
|
30
|
+
/** Normalises one entry into a `Feature`, whichever spelling it arrived in. */
|
|
31
|
+
function normaliseFeature(raw) {
|
|
32
|
+
const tags = field(raw, 'tags', 'Tags');
|
|
33
|
+
return {
|
|
34
|
+
key: String(field(raw, 'key', 'Key') ?? ''),
|
|
35
|
+
value: String(field(raw, 'value', 'Value') ?? ''),
|
|
36
|
+
activeAt: date(field(raw, 'activeAt', 'ActiveAt')),
|
|
37
|
+
disabledAt: date(field(raw, 'disabledAt', 'DisabledAt')),
|
|
38
|
+
// Filtered rather than asserted: `as string[]` is a compile-time claim
|
|
39
|
+
// that a backend sending a mixed array would quietly break, handing
|
|
40
|
+
// callers a non-string through a field typed as string.
|
|
41
|
+
tags: Array.isArray(tags)
|
|
42
|
+
? tags.filter((tag) => typeof tag === 'string')
|
|
43
|
+
: [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Normalises a whole response into features keyed by their key.
|
|
48
|
+
*
|
|
49
|
+
* Three envelopes are accepted, because the backend uses all three:
|
|
50
|
+
*
|
|
51
|
+
* - `{ "toggles": [...] }` for a UUID group;
|
|
52
|
+
* - `{ "value": [...] }`, the same thing under a different name;
|
|
53
|
+
* - a flat object for a single toggle.
|
|
54
|
+
*
|
|
55
|
+
* An entry without a usable key is skipped rather than stored under `""`,
|
|
56
|
+
* where `isEnabled("")` could reach it.
|
|
57
|
+
*/
|
|
58
|
+
function normaliseCollection(response) {
|
|
59
|
+
if (response === null || typeof response !== 'object')
|
|
60
|
+
return {};
|
|
61
|
+
const body = response;
|
|
62
|
+
const collection = Array.isArray(body['toggles'])
|
|
63
|
+
? body['toggles']
|
|
64
|
+
: Array.isArray(body['value'])
|
|
65
|
+
? body['value']
|
|
66
|
+
: undefined;
|
|
67
|
+
// A single toggle comes back flat, not wrapped. Older code only handled the
|
|
68
|
+
// collections and dropped this shape entirely.
|
|
69
|
+
const entries = collection ?? [body];
|
|
70
|
+
const data = {};
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (entry === null || typeof entry !== 'object')
|
|
73
|
+
continue;
|
|
74
|
+
const feature = normaliseFeature(entry);
|
|
75
|
+
if (feature.key !== '')
|
|
76
|
+
data[feature.key] = feature;
|
|
77
|
+
}
|
|
78
|
+
return data;
|
|
79
|
+
}
|