@toglio/js 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/dist/index.d.mts +108 -0
- package/dist/index.d.ts +108 -0
- package/dist/index.js +287 -0
- package/dist/index.mjs +260 -0
- package/package.json +33 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
type FlagValue = boolean | string | number;
|
|
2
|
+
interface FlagMap {
|
|
3
|
+
[key: string]: FlagValue;
|
|
4
|
+
}
|
|
5
|
+
interface SdkTargetingCondition {
|
|
6
|
+
attribute: string;
|
|
7
|
+
operator: string;
|
|
8
|
+
values: string[];
|
|
9
|
+
segmentId?: string | null;
|
|
10
|
+
}
|
|
11
|
+
interface SdkTargetingRule {
|
|
12
|
+
id: string;
|
|
13
|
+
name?: string | null;
|
|
14
|
+
priority: number;
|
|
15
|
+
value: string;
|
|
16
|
+
conditions: SdkTargetingCondition[];
|
|
17
|
+
}
|
|
18
|
+
interface SdkSegment {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
members: string[];
|
|
22
|
+
}
|
|
23
|
+
interface SdkResponse {
|
|
24
|
+
organization: string;
|
|
25
|
+
project: string;
|
|
26
|
+
environment: string;
|
|
27
|
+
flags: FlagMap;
|
|
28
|
+
payloads?: Record<string, string>;
|
|
29
|
+
targeting?: Record<string, SdkTargetingRule[]>;
|
|
30
|
+
segments?: Record<string, SdkSegment>;
|
|
31
|
+
}
|
|
32
|
+
/** Atributos extras do usuário usados nas regras de targeting. */
|
|
33
|
+
type UserAttributes = Record<string, string | string[]>;
|
|
34
|
+
interface FeatureFlagsClientConfig {
|
|
35
|
+
/** URL base da API (ex: https://api.suaplataforma.com) */
|
|
36
|
+
baseUrl: string;
|
|
37
|
+
/** Slug da organização */
|
|
38
|
+
organization: string;
|
|
39
|
+
/** Slug do projeto */
|
|
40
|
+
project: string;
|
|
41
|
+
/** Slug do ambiente (dev | hml | prod) */
|
|
42
|
+
environment: string;
|
|
43
|
+
/** Token público de leitura do ambiente, enviado no header X-SDK-Token. */
|
|
44
|
+
apiKey: string;
|
|
45
|
+
/** Identificador estável do usuário final para rollout gradual e targeting. */
|
|
46
|
+
userId?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Atributos extras do usuário para regras de targeting avançado.
|
|
49
|
+
* Exemplos: { city: 'São Paulo', plan: 'premium', groups: ['beta'] }
|
|
50
|
+
*/
|
|
51
|
+
attributes?: UserAttributes;
|
|
52
|
+
/** Intervalo de polling em ms. Default: 30000. Use 0 para desativar. */
|
|
53
|
+
pollingInterval?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Valores de fallback usados quando o servidor está offline ou a flag
|
|
56
|
+
* não existe. Evita que a aplicação quebre em caso de indisponibilidade.
|
|
57
|
+
*/
|
|
58
|
+
defaults?: Partial<FlagMap>;
|
|
59
|
+
/** Callback chamado quando alguma flag muda de valor após um poll. */
|
|
60
|
+
onChange?: (changes: FlagChange[]) => void;
|
|
61
|
+
}
|
|
62
|
+
interface FlagChange {
|
|
63
|
+
key: string;
|
|
64
|
+
previous: FlagValue | undefined;
|
|
65
|
+
current: FlagValue;
|
|
66
|
+
}
|
|
67
|
+
interface ClientStatus {
|
|
68
|
+
initialized: boolean;
|
|
69
|
+
lastFetchAt: Date | null;
|
|
70
|
+
fetchError: Error | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
declare class FeatureFlagsClient {
|
|
74
|
+
private flags;
|
|
75
|
+
private payloads;
|
|
76
|
+
private targeting;
|
|
77
|
+
private segments;
|
|
78
|
+
private readonly status;
|
|
79
|
+
private readonly poller;
|
|
80
|
+
private readonly config;
|
|
81
|
+
private readonly changeListeners;
|
|
82
|
+
constructor(config: FeatureFlagsClientConfig);
|
|
83
|
+
initialize(): Promise<void>;
|
|
84
|
+
destroy(): void;
|
|
85
|
+
isEnabled(key: string, defaultValue?: boolean): boolean;
|
|
86
|
+
getString(key: string, defaultValue?: string): string;
|
|
87
|
+
getNumber(key: string, defaultValue?: number): number;
|
|
88
|
+
getAll(): FlagMap;
|
|
89
|
+
getVariant(key: string, defaultValue?: string): string;
|
|
90
|
+
getVariantPayload(key: string): unknown;
|
|
91
|
+
getStatus(): ClientStatus;
|
|
92
|
+
addOnChange(callback: (changes: FlagChange[]) => void): () => void;
|
|
93
|
+
/**
|
|
94
|
+
* Evaluate a flag applying targeting rules first, then falling back to the
|
|
95
|
+
* server-resolved value (which already accounts for rollout %).
|
|
96
|
+
*/
|
|
97
|
+
private evaluate;
|
|
98
|
+
private matchTargetingRules;
|
|
99
|
+
private ruleMatches;
|
|
100
|
+
private conditionMatches;
|
|
101
|
+
private buildUrl;
|
|
102
|
+
private fetchFlags;
|
|
103
|
+
private storageKey;
|
|
104
|
+
private saveToStorage;
|
|
105
|
+
private loadFromStorage;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export { type ClientStatus, FeatureFlagsClient, type FeatureFlagsClientConfig, type FlagChange, type FlagMap, type FlagValue, type SdkResponse };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
type FlagValue = boolean | string | number;
|
|
2
|
+
interface FlagMap {
|
|
3
|
+
[key: string]: FlagValue;
|
|
4
|
+
}
|
|
5
|
+
interface SdkTargetingCondition {
|
|
6
|
+
attribute: string;
|
|
7
|
+
operator: string;
|
|
8
|
+
values: string[];
|
|
9
|
+
segmentId?: string | null;
|
|
10
|
+
}
|
|
11
|
+
interface SdkTargetingRule {
|
|
12
|
+
id: string;
|
|
13
|
+
name?: string | null;
|
|
14
|
+
priority: number;
|
|
15
|
+
value: string;
|
|
16
|
+
conditions: SdkTargetingCondition[];
|
|
17
|
+
}
|
|
18
|
+
interface SdkSegment {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
members: string[];
|
|
22
|
+
}
|
|
23
|
+
interface SdkResponse {
|
|
24
|
+
organization: string;
|
|
25
|
+
project: string;
|
|
26
|
+
environment: string;
|
|
27
|
+
flags: FlagMap;
|
|
28
|
+
payloads?: Record<string, string>;
|
|
29
|
+
targeting?: Record<string, SdkTargetingRule[]>;
|
|
30
|
+
segments?: Record<string, SdkSegment>;
|
|
31
|
+
}
|
|
32
|
+
/** Atributos extras do usuário usados nas regras de targeting. */
|
|
33
|
+
type UserAttributes = Record<string, string | string[]>;
|
|
34
|
+
interface FeatureFlagsClientConfig {
|
|
35
|
+
/** URL base da API (ex: https://api.suaplataforma.com) */
|
|
36
|
+
baseUrl: string;
|
|
37
|
+
/** Slug da organização */
|
|
38
|
+
organization: string;
|
|
39
|
+
/** Slug do projeto */
|
|
40
|
+
project: string;
|
|
41
|
+
/** Slug do ambiente (dev | hml | prod) */
|
|
42
|
+
environment: string;
|
|
43
|
+
/** Token público de leitura do ambiente, enviado no header X-SDK-Token. */
|
|
44
|
+
apiKey: string;
|
|
45
|
+
/** Identificador estável do usuário final para rollout gradual e targeting. */
|
|
46
|
+
userId?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Atributos extras do usuário para regras de targeting avançado.
|
|
49
|
+
* Exemplos: { city: 'São Paulo', plan: 'premium', groups: ['beta'] }
|
|
50
|
+
*/
|
|
51
|
+
attributes?: UserAttributes;
|
|
52
|
+
/** Intervalo de polling em ms. Default: 30000. Use 0 para desativar. */
|
|
53
|
+
pollingInterval?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Valores de fallback usados quando o servidor está offline ou a flag
|
|
56
|
+
* não existe. Evita que a aplicação quebre em caso de indisponibilidade.
|
|
57
|
+
*/
|
|
58
|
+
defaults?: Partial<FlagMap>;
|
|
59
|
+
/** Callback chamado quando alguma flag muda de valor após um poll. */
|
|
60
|
+
onChange?: (changes: FlagChange[]) => void;
|
|
61
|
+
}
|
|
62
|
+
interface FlagChange {
|
|
63
|
+
key: string;
|
|
64
|
+
previous: FlagValue | undefined;
|
|
65
|
+
current: FlagValue;
|
|
66
|
+
}
|
|
67
|
+
interface ClientStatus {
|
|
68
|
+
initialized: boolean;
|
|
69
|
+
lastFetchAt: Date | null;
|
|
70
|
+
fetchError: Error | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
declare class FeatureFlagsClient {
|
|
74
|
+
private flags;
|
|
75
|
+
private payloads;
|
|
76
|
+
private targeting;
|
|
77
|
+
private segments;
|
|
78
|
+
private readonly status;
|
|
79
|
+
private readonly poller;
|
|
80
|
+
private readonly config;
|
|
81
|
+
private readonly changeListeners;
|
|
82
|
+
constructor(config: FeatureFlagsClientConfig);
|
|
83
|
+
initialize(): Promise<void>;
|
|
84
|
+
destroy(): void;
|
|
85
|
+
isEnabled(key: string, defaultValue?: boolean): boolean;
|
|
86
|
+
getString(key: string, defaultValue?: string): string;
|
|
87
|
+
getNumber(key: string, defaultValue?: number): number;
|
|
88
|
+
getAll(): FlagMap;
|
|
89
|
+
getVariant(key: string, defaultValue?: string): string;
|
|
90
|
+
getVariantPayload(key: string): unknown;
|
|
91
|
+
getStatus(): ClientStatus;
|
|
92
|
+
addOnChange(callback: (changes: FlagChange[]) => void): () => void;
|
|
93
|
+
/**
|
|
94
|
+
* Evaluate a flag applying targeting rules first, then falling back to the
|
|
95
|
+
* server-resolved value (which already accounts for rollout %).
|
|
96
|
+
*/
|
|
97
|
+
private evaluate;
|
|
98
|
+
private matchTargetingRules;
|
|
99
|
+
private ruleMatches;
|
|
100
|
+
private conditionMatches;
|
|
101
|
+
private buildUrl;
|
|
102
|
+
private fetchFlags;
|
|
103
|
+
private storageKey;
|
|
104
|
+
private saveToStorage;
|
|
105
|
+
private loadFromStorage;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export { type ClientStatus, FeatureFlagsClient, type FeatureFlagsClientConfig, type FlagChange, type FlagMap, type FlagValue, type SdkResponse };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
FeatureFlagsClient: () => FeatureFlagsClient
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
|
|
27
|
+
// src/poller.ts
|
|
28
|
+
var Poller = class {
|
|
29
|
+
constructor(fetch2, intervalMs) {
|
|
30
|
+
this.fetch = fetch2;
|
|
31
|
+
this.intervalMs = intervalMs;
|
|
32
|
+
this.timer = null;
|
|
33
|
+
this.running = false;
|
|
34
|
+
}
|
|
35
|
+
start() {
|
|
36
|
+
if (this.running || this.intervalMs <= 0) return;
|
|
37
|
+
this.running = true;
|
|
38
|
+
this.schedule();
|
|
39
|
+
}
|
|
40
|
+
stop() {
|
|
41
|
+
this.running = false;
|
|
42
|
+
if (this.timer !== null) {
|
|
43
|
+
clearTimeout(this.timer);
|
|
44
|
+
this.timer = null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
schedule() {
|
|
48
|
+
this.timer = setTimeout(async () => {
|
|
49
|
+
if (!this.running) return;
|
|
50
|
+
try {
|
|
51
|
+
await this.fetch();
|
|
52
|
+
} catch {
|
|
53
|
+
}
|
|
54
|
+
if (this.running) this.schedule();
|
|
55
|
+
}, this.intervalMs);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// src/client.ts
|
|
60
|
+
var STORAGE_KEY_PREFIX = "ff:cache:";
|
|
61
|
+
var FeatureFlagsClient = class {
|
|
62
|
+
constructor(config) {
|
|
63
|
+
this.flags = {};
|
|
64
|
+
this.payloads = {};
|
|
65
|
+
this.targeting = {};
|
|
66
|
+
this.segments = {};
|
|
67
|
+
this.status = {
|
|
68
|
+
initialized: false,
|
|
69
|
+
lastFetchAt: null,
|
|
70
|
+
fetchError: null
|
|
71
|
+
};
|
|
72
|
+
this.changeListeners = [];
|
|
73
|
+
this.config = {
|
|
74
|
+
pollingInterval: 3e4,
|
|
75
|
+
defaults: {},
|
|
76
|
+
onChange: void 0,
|
|
77
|
+
attributes: void 0,
|
|
78
|
+
...config
|
|
79
|
+
};
|
|
80
|
+
this.poller = new Poller(
|
|
81
|
+
() => this.fetchFlags(),
|
|
82
|
+
this.config.pollingInterval
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
async initialize() {
|
|
86
|
+
const cached = this.loadFromStorage();
|
|
87
|
+
if (cached) {
|
|
88
|
+
this.flags = cached;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
await this.fetchFlags();
|
|
92
|
+
} catch (err) {
|
|
93
|
+
this.status.fetchError = err instanceof Error ? err : new Error(String(err));
|
|
94
|
+
}
|
|
95
|
+
this.status.initialized = true;
|
|
96
|
+
this.poller.start();
|
|
97
|
+
}
|
|
98
|
+
destroy() {
|
|
99
|
+
this.poller.stop();
|
|
100
|
+
}
|
|
101
|
+
// ─── Flag readers ───────────────────────────────────────────────────────────
|
|
102
|
+
isEnabled(key, defaultValue = false) {
|
|
103
|
+
const val = this.evaluate(key);
|
|
104
|
+
if (val === void 0) return defaultValue;
|
|
105
|
+
return val === true || val === "true";
|
|
106
|
+
}
|
|
107
|
+
getString(key, defaultValue = "") {
|
|
108
|
+
const val = this.evaluate(key);
|
|
109
|
+
if (val === void 0) return defaultValue;
|
|
110
|
+
return String(val);
|
|
111
|
+
}
|
|
112
|
+
getNumber(key, defaultValue = 0) {
|
|
113
|
+
const val = this.evaluate(key);
|
|
114
|
+
if (val === void 0) return defaultValue;
|
|
115
|
+
const n = Number(val);
|
|
116
|
+
return Number.isNaN(n) ? defaultValue : n;
|
|
117
|
+
}
|
|
118
|
+
getAll() {
|
|
119
|
+
return { ...this.flags };
|
|
120
|
+
}
|
|
121
|
+
getVariant(key, defaultValue = "control") {
|
|
122
|
+
const val = this.evaluate(key);
|
|
123
|
+
if (typeof val === "string" && val !== "") return val;
|
|
124
|
+
return defaultValue;
|
|
125
|
+
}
|
|
126
|
+
getVariantPayload(key) {
|
|
127
|
+
const raw = this.payloads[key];
|
|
128
|
+
if (!raw) return null;
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(raw);
|
|
131
|
+
} catch {
|
|
132
|
+
return raw;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
getStatus() {
|
|
136
|
+
return { ...this.status };
|
|
137
|
+
}
|
|
138
|
+
addOnChange(callback) {
|
|
139
|
+
this.changeListeners.push(callback);
|
|
140
|
+
return () => {
|
|
141
|
+
const idx = this.changeListeners.indexOf(callback);
|
|
142
|
+
if (idx !== -1) this.changeListeners.splice(idx, 1);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// ─── Targeting evaluation ───────────────────────────────────────────────────
|
|
146
|
+
/**
|
|
147
|
+
* Evaluate a flag applying targeting rules first, then falling back to the
|
|
148
|
+
* server-resolved value (which already accounts for rollout %).
|
|
149
|
+
*/
|
|
150
|
+
evaluate(key) {
|
|
151
|
+
const rules = this.targeting[key];
|
|
152
|
+
if (rules && rules.length > 0 && this.config.userId) {
|
|
153
|
+
const matched = this.matchTargetingRules(rules);
|
|
154
|
+
if (matched !== void 0) return matched;
|
|
155
|
+
}
|
|
156
|
+
if (key in this.flags) return this.flags[key];
|
|
157
|
+
if (this.config.defaults && key in this.config.defaults) {
|
|
158
|
+
return this.config.defaults[key];
|
|
159
|
+
}
|
|
160
|
+
return void 0;
|
|
161
|
+
}
|
|
162
|
+
matchTargetingRules(rules) {
|
|
163
|
+
const sorted = [...rules].sort((a, b) => a.priority - b.priority);
|
|
164
|
+
for (const rule of sorted) {
|
|
165
|
+
if (this.ruleMatches(rule)) return rule.value;
|
|
166
|
+
}
|
|
167
|
+
return void 0;
|
|
168
|
+
}
|
|
169
|
+
ruleMatches(rule) {
|
|
170
|
+
return rule.conditions.every((cond) => this.conditionMatches(cond));
|
|
171
|
+
}
|
|
172
|
+
conditionMatches(cond) {
|
|
173
|
+
var _a, _b, _c;
|
|
174
|
+
const { attribute, operator, values, segmentId } = cond;
|
|
175
|
+
const userId = (_a = this.config.userId) != null ? _a : "";
|
|
176
|
+
const attrs = (_b = this.config.attributes) != null ? _b : {};
|
|
177
|
+
if (attribute === "segment" || attribute === "group") {
|
|
178
|
+
const id = segmentId != null ? segmentId : values[0];
|
|
179
|
+
const segment = id ? this.segments[id] : void 0;
|
|
180
|
+
return segment ? segment.members.includes(userId) : false;
|
|
181
|
+
}
|
|
182
|
+
const rawValue = attribute === "userId" ? userId : attrs[attribute];
|
|
183
|
+
if (rawValue === void 0) return false;
|
|
184
|
+
const userValues = Array.isArray(rawValue) ? rawValue : [String(rawValue)];
|
|
185
|
+
switch (operator) {
|
|
186
|
+
case "IN":
|
|
187
|
+
return values.some((v) => userValues.includes(v));
|
|
188
|
+
case "NOT_IN":
|
|
189
|
+
return !values.some((v) => userValues.includes(v));
|
|
190
|
+
case "EQUALS":
|
|
191
|
+
return userValues[0] === values[0];
|
|
192
|
+
case "NOT_EQUALS":
|
|
193
|
+
return userValues[0] !== values[0];
|
|
194
|
+
case "CONTAINS":
|
|
195
|
+
return userValues.some((uv) => {
|
|
196
|
+
var _a2;
|
|
197
|
+
return uv.includes((_a2 = values[0]) != null ? _a2 : "");
|
|
198
|
+
});
|
|
199
|
+
case "MATCHES_REGEX": {
|
|
200
|
+
try {
|
|
201
|
+
const re = new RegExp((_c = values[0]) != null ? _c : "");
|
|
202
|
+
return userValues.some((uv) => re.test(uv));
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
default:
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// ─── Private helpers ────────────────────────────────────────────────────────
|
|
212
|
+
buildUrl() {
|
|
213
|
+
const { baseUrl, organization, project, environment } = this.config;
|
|
214
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
215
|
+
return `${base}/sdk/organizations/${organization}/projects/${project}/environments/${environment}`;
|
|
216
|
+
}
|
|
217
|
+
async fetchFlags() {
|
|
218
|
+
var _a, _b, _c, _d;
|
|
219
|
+
const controller = new AbortController();
|
|
220
|
+
const timeoutId = setTimeout(() => controller.abort(), 5e3);
|
|
221
|
+
try {
|
|
222
|
+
const headers = {
|
|
223
|
+
Accept: "application/json",
|
|
224
|
+
"X-SDK-Token": this.config.apiKey
|
|
225
|
+
};
|
|
226
|
+
if (this.config.userId) {
|
|
227
|
+
headers["X-Toglio-User-Id"] = this.config.userId;
|
|
228
|
+
}
|
|
229
|
+
const response = await fetch(this.buildUrl(), { headers, signal: controller.signal });
|
|
230
|
+
if (!response.ok) {
|
|
231
|
+
throw new Error(`SDK fetch failed: ${response.status} ${response.statusText}`);
|
|
232
|
+
}
|
|
233
|
+
const body = await response.json();
|
|
234
|
+
const incoming = (_a = body.flags) != null ? _a : {};
|
|
235
|
+
const changes = [];
|
|
236
|
+
for (const key of /* @__PURE__ */ new Set([
|
|
237
|
+
...Object.keys(this.flags),
|
|
238
|
+
...Object.keys(incoming)
|
|
239
|
+
])) {
|
|
240
|
+
const previous = this.flags[key];
|
|
241
|
+
const current = incoming[key];
|
|
242
|
+
if (previous !== current) {
|
|
243
|
+
changes.push({ key, previous, current });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
this.flags = incoming;
|
|
247
|
+
this.payloads = (_b = body.payloads) != null ? _b : {};
|
|
248
|
+
this.targeting = (_c = body.targeting) != null ? _c : {};
|
|
249
|
+
this.segments = (_d = body.segments) != null ? _d : {};
|
|
250
|
+
this.status.lastFetchAt = /* @__PURE__ */ new Date();
|
|
251
|
+
this.status.fetchError = null;
|
|
252
|
+
this.saveToStorage(incoming);
|
|
253
|
+
if (changes.length > 0) {
|
|
254
|
+
if (this.config.onChange) this.config.onChange(changes);
|
|
255
|
+
for (const listener of this.changeListeners) listener(changes);
|
|
256
|
+
}
|
|
257
|
+
} finally {
|
|
258
|
+
clearTimeout(timeoutId);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
storageKey() {
|
|
262
|
+
var _a;
|
|
263
|
+
return `${STORAGE_KEY_PREFIX}${this.config.organization}:${this.config.project}:${this.config.environment}:${(_a = this.config.userId) != null ? _a : "anonymous"}`;
|
|
264
|
+
}
|
|
265
|
+
saveToStorage(flags) {
|
|
266
|
+
try {
|
|
267
|
+
if (typeof localStorage !== "undefined") {
|
|
268
|
+
localStorage.setItem(this.storageKey(), JSON.stringify(flags));
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
loadFromStorage() {
|
|
274
|
+
try {
|
|
275
|
+
if (typeof localStorage !== "undefined") {
|
|
276
|
+
const raw = localStorage.getItem(this.storageKey());
|
|
277
|
+
if (raw) return JSON.parse(raw);
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
285
|
+
0 && (module.exports = {
|
|
286
|
+
FeatureFlagsClient
|
|
287
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// src/poller.ts
|
|
2
|
+
var Poller = class {
|
|
3
|
+
constructor(fetch2, intervalMs) {
|
|
4
|
+
this.fetch = fetch2;
|
|
5
|
+
this.intervalMs = intervalMs;
|
|
6
|
+
this.timer = null;
|
|
7
|
+
this.running = false;
|
|
8
|
+
}
|
|
9
|
+
start() {
|
|
10
|
+
if (this.running || this.intervalMs <= 0) return;
|
|
11
|
+
this.running = true;
|
|
12
|
+
this.schedule();
|
|
13
|
+
}
|
|
14
|
+
stop() {
|
|
15
|
+
this.running = false;
|
|
16
|
+
if (this.timer !== null) {
|
|
17
|
+
clearTimeout(this.timer);
|
|
18
|
+
this.timer = null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
schedule() {
|
|
22
|
+
this.timer = setTimeout(async () => {
|
|
23
|
+
if (!this.running) return;
|
|
24
|
+
try {
|
|
25
|
+
await this.fetch();
|
|
26
|
+
} catch {
|
|
27
|
+
}
|
|
28
|
+
if (this.running) this.schedule();
|
|
29
|
+
}, this.intervalMs);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// src/client.ts
|
|
34
|
+
var STORAGE_KEY_PREFIX = "ff:cache:";
|
|
35
|
+
var FeatureFlagsClient = class {
|
|
36
|
+
constructor(config) {
|
|
37
|
+
this.flags = {};
|
|
38
|
+
this.payloads = {};
|
|
39
|
+
this.targeting = {};
|
|
40
|
+
this.segments = {};
|
|
41
|
+
this.status = {
|
|
42
|
+
initialized: false,
|
|
43
|
+
lastFetchAt: null,
|
|
44
|
+
fetchError: null
|
|
45
|
+
};
|
|
46
|
+
this.changeListeners = [];
|
|
47
|
+
this.config = {
|
|
48
|
+
pollingInterval: 3e4,
|
|
49
|
+
defaults: {},
|
|
50
|
+
onChange: void 0,
|
|
51
|
+
attributes: void 0,
|
|
52
|
+
...config
|
|
53
|
+
};
|
|
54
|
+
this.poller = new Poller(
|
|
55
|
+
() => this.fetchFlags(),
|
|
56
|
+
this.config.pollingInterval
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
async initialize() {
|
|
60
|
+
const cached = this.loadFromStorage();
|
|
61
|
+
if (cached) {
|
|
62
|
+
this.flags = cached;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
await this.fetchFlags();
|
|
66
|
+
} catch (err) {
|
|
67
|
+
this.status.fetchError = err instanceof Error ? err : new Error(String(err));
|
|
68
|
+
}
|
|
69
|
+
this.status.initialized = true;
|
|
70
|
+
this.poller.start();
|
|
71
|
+
}
|
|
72
|
+
destroy() {
|
|
73
|
+
this.poller.stop();
|
|
74
|
+
}
|
|
75
|
+
// ─── Flag readers ───────────────────────────────────────────────────────────
|
|
76
|
+
isEnabled(key, defaultValue = false) {
|
|
77
|
+
const val = this.evaluate(key);
|
|
78
|
+
if (val === void 0) return defaultValue;
|
|
79
|
+
return val === true || val === "true";
|
|
80
|
+
}
|
|
81
|
+
getString(key, defaultValue = "") {
|
|
82
|
+
const val = this.evaluate(key);
|
|
83
|
+
if (val === void 0) return defaultValue;
|
|
84
|
+
return String(val);
|
|
85
|
+
}
|
|
86
|
+
getNumber(key, defaultValue = 0) {
|
|
87
|
+
const val = this.evaluate(key);
|
|
88
|
+
if (val === void 0) return defaultValue;
|
|
89
|
+
const n = Number(val);
|
|
90
|
+
return Number.isNaN(n) ? defaultValue : n;
|
|
91
|
+
}
|
|
92
|
+
getAll() {
|
|
93
|
+
return { ...this.flags };
|
|
94
|
+
}
|
|
95
|
+
getVariant(key, defaultValue = "control") {
|
|
96
|
+
const val = this.evaluate(key);
|
|
97
|
+
if (typeof val === "string" && val !== "") return val;
|
|
98
|
+
return defaultValue;
|
|
99
|
+
}
|
|
100
|
+
getVariantPayload(key) {
|
|
101
|
+
const raw = this.payloads[key];
|
|
102
|
+
if (!raw) return null;
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(raw);
|
|
105
|
+
} catch {
|
|
106
|
+
return raw;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
getStatus() {
|
|
110
|
+
return { ...this.status };
|
|
111
|
+
}
|
|
112
|
+
addOnChange(callback) {
|
|
113
|
+
this.changeListeners.push(callback);
|
|
114
|
+
return () => {
|
|
115
|
+
const idx = this.changeListeners.indexOf(callback);
|
|
116
|
+
if (idx !== -1) this.changeListeners.splice(idx, 1);
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// ─── Targeting evaluation ───────────────────────────────────────────────────
|
|
120
|
+
/**
|
|
121
|
+
* Evaluate a flag applying targeting rules first, then falling back to the
|
|
122
|
+
* server-resolved value (which already accounts for rollout %).
|
|
123
|
+
*/
|
|
124
|
+
evaluate(key) {
|
|
125
|
+
const rules = this.targeting[key];
|
|
126
|
+
if (rules && rules.length > 0 && this.config.userId) {
|
|
127
|
+
const matched = this.matchTargetingRules(rules);
|
|
128
|
+
if (matched !== void 0) return matched;
|
|
129
|
+
}
|
|
130
|
+
if (key in this.flags) return this.flags[key];
|
|
131
|
+
if (this.config.defaults && key in this.config.defaults) {
|
|
132
|
+
return this.config.defaults[key];
|
|
133
|
+
}
|
|
134
|
+
return void 0;
|
|
135
|
+
}
|
|
136
|
+
matchTargetingRules(rules) {
|
|
137
|
+
const sorted = [...rules].sort((a, b) => a.priority - b.priority);
|
|
138
|
+
for (const rule of sorted) {
|
|
139
|
+
if (this.ruleMatches(rule)) return rule.value;
|
|
140
|
+
}
|
|
141
|
+
return void 0;
|
|
142
|
+
}
|
|
143
|
+
ruleMatches(rule) {
|
|
144
|
+
return rule.conditions.every((cond) => this.conditionMatches(cond));
|
|
145
|
+
}
|
|
146
|
+
conditionMatches(cond) {
|
|
147
|
+
var _a, _b, _c;
|
|
148
|
+
const { attribute, operator, values, segmentId } = cond;
|
|
149
|
+
const userId = (_a = this.config.userId) != null ? _a : "";
|
|
150
|
+
const attrs = (_b = this.config.attributes) != null ? _b : {};
|
|
151
|
+
if (attribute === "segment" || attribute === "group") {
|
|
152
|
+
const id = segmentId != null ? segmentId : values[0];
|
|
153
|
+
const segment = id ? this.segments[id] : void 0;
|
|
154
|
+
return segment ? segment.members.includes(userId) : false;
|
|
155
|
+
}
|
|
156
|
+
const rawValue = attribute === "userId" ? userId : attrs[attribute];
|
|
157
|
+
if (rawValue === void 0) return false;
|
|
158
|
+
const userValues = Array.isArray(rawValue) ? rawValue : [String(rawValue)];
|
|
159
|
+
switch (operator) {
|
|
160
|
+
case "IN":
|
|
161
|
+
return values.some((v) => userValues.includes(v));
|
|
162
|
+
case "NOT_IN":
|
|
163
|
+
return !values.some((v) => userValues.includes(v));
|
|
164
|
+
case "EQUALS":
|
|
165
|
+
return userValues[0] === values[0];
|
|
166
|
+
case "NOT_EQUALS":
|
|
167
|
+
return userValues[0] !== values[0];
|
|
168
|
+
case "CONTAINS":
|
|
169
|
+
return userValues.some((uv) => {
|
|
170
|
+
var _a2;
|
|
171
|
+
return uv.includes((_a2 = values[0]) != null ? _a2 : "");
|
|
172
|
+
});
|
|
173
|
+
case "MATCHES_REGEX": {
|
|
174
|
+
try {
|
|
175
|
+
const re = new RegExp((_c = values[0]) != null ? _c : "");
|
|
176
|
+
return userValues.some((uv) => re.test(uv));
|
|
177
|
+
} catch {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
default:
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// ─── Private helpers ────────────────────────────────────────────────────────
|
|
186
|
+
buildUrl() {
|
|
187
|
+
const { baseUrl, organization, project, environment } = this.config;
|
|
188
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
189
|
+
return `${base}/sdk/organizations/${organization}/projects/${project}/environments/${environment}`;
|
|
190
|
+
}
|
|
191
|
+
async fetchFlags() {
|
|
192
|
+
var _a, _b, _c, _d;
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
const timeoutId = setTimeout(() => controller.abort(), 5e3);
|
|
195
|
+
try {
|
|
196
|
+
const headers = {
|
|
197
|
+
Accept: "application/json",
|
|
198
|
+
"X-SDK-Token": this.config.apiKey
|
|
199
|
+
};
|
|
200
|
+
if (this.config.userId) {
|
|
201
|
+
headers["X-Toglio-User-Id"] = this.config.userId;
|
|
202
|
+
}
|
|
203
|
+
const response = await fetch(this.buildUrl(), { headers, signal: controller.signal });
|
|
204
|
+
if (!response.ok) {
|
|
205
|
+
throw new Error(`SDK fetch failed: ${response.status} ${response.statusText}`);
|
|
206
|
+
}
|
|
207
|
+
const body = await response.json();
|
|
208
|
+
const incoming = (_a = body.flags) != null ? _a : {};
|
|
209
|
+
const changes = [];
|
|
210
|
+
for (const key of /* @__PURE__ */ new Set([
|
|
211
|
+
...Object.keys(this.flags),
|
|
212
|
+
...Object.keys(incoming)
|
|
213
|
+
])) {
|
|
214
|
+
const previous = this.flags[key];
|
|
215
|
+
const current = incoming[key];
|
|
216
|
+
if (previous !== current) {
|
|
217
|
+
changes.push({ key, previous, current });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
this.flags = incoming;
|
|
221
|
+
this.payloads = (_b = body.payloads) != null ? _b : {};
|
|
222
|
+
this.targeting = (_c = body.targeting) != null ? _c : {};
|
|
223
|
+
this.segments = (_d = body.segments) != null ? _d : {};
|
|
224
|
+
this.status.lastFetchAt = /* @__PURE__ */ new Date();
|
|
225
|
+
this.status.fetchError = null;
|
|
226
|
+
this.saveToStorage(incoming);
|
|
227
|
+
if (changes.length > 0) {
|
|
228
|
+
if (this.config.onChange) this.config.onChange(changes);
|
|
229
|
+
for (const listener of this.changeListeners) listener(changes);
|
|
230
|
+
}
|
|
231
|
+
} finally {
|
|
232
|
+
clearTimeout(timeoutId);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
storageKey() {
|
|
236
|
+
var _a;
|
|
237
|
+
return `${STORAGE_KEY_PREFIX}${this.config.organization}:${this.config.project}:${this.config.environment}:${(_a = this.config.userId) != null ? _a : "anonymous"}`;
|
|
238
|
+
}
|
|
239
|
+
saveToStorage(flags) {
|
|
240
|
+
try {
|
|
241
|
+
if (typeof localStorage !== "undefined") {
|
|
242
|
+
localStorage.setItem(this.storageKey(), JSON.stringify(flags));
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
loadFromStorage() {
|
|
248
|
+
try {
|
|
249
|
+
if (typeof localStorage !== "undefined") {
|
|
250
|
+
const raw = localStorage.getItem(this.storageKey());
|
|
251
|
+
if (raw) return JSON.parse(raw);
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
export {
|
|
259
|
+
FeatureFlagsClient
|
|
260
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@toglio/js",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Toglio — JavaScript/TypeScript SDK",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
20
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
21
|
+
"test": "echo \"No tests yet\""
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"feature-flags",
|
|
25
|
+
"sdk",
|
|
26
|
+
"typescript"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"tsup": "^8.5.1",
|
|
31
|
+
"typescript": "^5.5.0"
|
|
32
|
+
}
|
|
33
|
+
}
|